From 4ee53a0cabec47d1be4713715313c60024abc003 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 6 Sep 2026 00:25:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(#1718):=20=E5=BE=AE=E4=BF=A1=E7=99=BB?= =?UTF-8?q?=E5=BD=95/=E7=BB=91=E5=AE=9A=E6=94=B9=E4=B8=BA=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E5=86=85=E5=B5=8C=E4=BA=8C=E7=BB=B4=E7=A0=81=EF=BC=88?= =?UTF-8?q?WxLogin=20self=5Fredirect=20+=20iframe=20postMessage=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E8=84=9A=E6=9C=AC=E5=8A=A0=E8=BD=BD=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E6=95=B4=E9=A1=B5=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/api/auth/wxLogin.ts | 112 ++++++++ .../auth/WechatQrModal/WechatQrModal.css | 74 +++++ .../components/auth/WechatQrModal/index.tsx | 265 ++++++++++++++++++ .../components/auth/WechatQrModal/messages.ts | 71 +++++ apps/web/src/pages/auth/Login.tsx | 68 +++-- .../web/src/pages/auth/WechatBindCallback.tsx | 31 +- apps/web/src/pages/auth/WechatCallback.tsx | 37 ++- apps/web/src/pages/profile/Settings.tsx | 26 +- apps/web/src/test/api/wxLogin.test.ts | 64 +++++ .../test/components/WechatQrModal.test.tsx | 165 +++++++++++ .../pages/auth/WechatBindCallback.test.tsx | 39 +++ .../test/pages/auth/WechatCallback.test.tsx | 61 ++++ 12 files changed, 956 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/api/auth/wxLogin.ts create mode 100644 apps/web/src/components/auth/WechatQrModal/WechatQrModal.css create mode 100644 apps/web/src/components/auth/WechatQrModal/index.tsx create mode 100644 apps/web/src/components/auth/WechatQrModal/messages.ts create mode 100644 apps/web/src/test/api/wxLogin.test.ts create mode 100644 apps/web/src/test/components/WechatQrModal.test.tsx diff --git a/apps/web/src/api/auth/wxLogin.ts b/apps/web/src/api/auth/wxLogin.ts new file mode 100644 index 000000000..98706076f --- /dev/null +++ b/apps/web/src/api/auth/wxLogin.ts @@ -0,0 +1,112 @@ +/** + * 微信扫码登录 WxLogin JS-SDK 动态加载与授权参数解析 + * + * 微信官网嵌入式二维码方案:页面引入 https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js + * 后挂载全局 window.WxLogin,new WxLogin({...}) 会在指定容器内渲染二维码 iframe。 + * 本模块负责:动态加载该脚本(带超时/失败检测)、从后端返回的 auth_url 中解析 + * WxLogin 所需的 appid / redirect_uri / state。 + */ + +const WX_LOGIN_SRC = "https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js" +/** 脚本加载超时(毫秒):超时视为加载失败,调用方回退整页跳转 */ +const WX_LOGIN_LOAD_TIMEOUT = 8000 + +/** WxLogin 构造参数(微信官方字段,保持原名) */ +export interface WxLoginOptions { + /** 是否内嵌二维码(回调在 iframe 内完成) */ + self_redirect: boolean + /** 二维码容器元素 id */ + id: string + /** 微信开放平台 AppID */ + appid: string + /** 应用授权作用域,网站应用固定 snsapi_login */ + scope: "snsapi_login" + /** 回调地址(需与微信开放平台配置一致,WxLogin 内部会 encodeURIComponent) */ + redirect_uri: string + /** 防 CSRF 随机串,由后端 state store 生成并在回调时一次性消费 */ + state: string + /** 二维码样式:black / white */ + style?: "black" | "white" + /** 自定义样式链接(可选) */ + href?: string +} + +/** 微信脚本挂载到 window 上的全局构造函数类型 */ +export interface WxLoginConstructor { + new (options: WxLoginOptions): unknown +} + +declare global { + interface Window { + WxLogin?: WxLoginConstructor + } +} + +let loadPromise: Promise | null = null + +/** + * 动态加载微信 WxLogin JS(单例:并发调用复用同一个 promise)。 + * 加载失败或超时会 reject,调用方应回退到整页跳转授权方式。 + */ +export function loadWxLoginScript(): Promise { + if (window.WxLogin) return Promise.resolve(window.WxLogin) + if (loadPromise) return loadPromise + + loadPromise = new Promise((resolve, reject) => { + const script = document.createElement("script") + script.src = WX_LOGIN_SRC + script.async = true + script.onload = () => { + if (window.WxLogin) { + resolve(window.WxLogin) + } else { + loadPromise = null + reject(new Error("微信登录脚本加载完成但 WxLogin 未挂载")) + } + } + script.onerror = () => { + loadPromise = null + script.remove() + reject(new Error("微信登录脚本加载失败")) + } + document.head.appendChild(script) + + // 超时兜底:部分网络环境下脚本既不 onload 也不 onerror + window.setTimeout(() => { + if (window.WxLogin) { + resolve(window.WxLogin) + return + } + loadPromise = null + script.remove() + reject(new Error("微信登录脚本加载超时")) + }, WX_LOGIN_LOAD_TIMEOUT) + }) + + return loadPromise +} + +/** 从微信授权链接 query 中解析出的 WxLogin 所需参数 */ +export interface ParsedWxAuthParams { + appid: string + /** 已 URL 解码的回调地址(传给 WxLogin 时由其内部再次编码) */ + redirect_uri: string + state: string +} + +/** + * 从后端返回的微信授权链接(https://open.weixin.qq.com/connect/qrconnect?appid=...&redirect_uri=...&state=...) + * 中解析 appid / redirect_uri / state。解析失败时返回 null,由调用方回退整页跳转。 + */ +export function parseWxAuthUrl(authUrl: string, stateFallback?: string): ParsedWxAuthParams | null { + try { + const url = new URL(authUrl) + const appid = url.searchParams.get("appid") + const redirectUri = url.searchParams.get("redirect_uri") + const state = url.searchParams.get("state") || stateFallback || "" + if (!appid || !redirectUri || !state) return null + return { appid, redirect_uri: redirectUri, state } + } catch { + return null + } +} diff --git a/apps/web/src/components/auth/WechatQrModal/WechatQrModal.css b/apps/web/src/components/auth/WechatQrModal/WechatQrModal.css new file mode 100644 index 000000000..3f5ea062d --- /dev/null +++ b/apps/web/src/components/auth/WechatQrModal/WechatQrModal.css @@ -0,0 +1,74 @@ +.xx-wechat-qr-modal { + position: relative; + padding: 8px 0 4px; + min-height: 320px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +/* 常驻二维码容器(WxLogin 渲染目标) */ +.xx-wechat-qr-container { + display: flex; + justify-content: center; + min-height: 260px; +} + +/* loading / error 遮罩层,覆盖在二维码容器之上 */ +.xx-wechat-qr-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: #fff; + text-align: center; + color: #666; +} + +.xx-wechat-qr-overlay p { + margin-top: 16px; + margin-bottom: 0; +} + +.xx-wechat-qr-container iframe { + border: none; +} + +.xx-wechat-qr-tip { + margin: 12px 0 0; + color: #666; + font-size: 14px; +} + +.xx-wechat-qr-error { + text-align: center; + width: 100%; +} + +.xx-wechat-qr-error-msg { + color: #ef4444; + font-size: 14px; + line-height: 1.6; + margin: 0 0 16px; + word-break: break-word; +} + +.xx-wechat-qr-error-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.xx-wechat-qr-fallback { + background: none; + border: none; + color: var(--primary-color, #3b82f6); + cursor: pointer; + font-size: 13px; + padding: 0; + text-decoration: underline; +} diff --git a/apps/web/src/components/auth/WechatQrModal/index.tsx b/apps/web/src/components/auth/WechatQrModal/index.tsx new file mode 100644 index 000000000..59e254c43 --- /dev/null +++ b/apps/web/src/components/auth/WechatQrModal/index.tsx @@ -0,0 +1,265 @@ +/** + * 微信扫码二维码弹窗(登录 / 绑定复用) + * + * 微信官方嵌入式二维码方案:弹窗内用 new WxLogin({ self_redirect: true }) 渲染二维码, + * 扫码后微信重定向到本站回调页(在二维码 iframe 内加载),回调页通过 postMessage + * 把成功/失败结果通知本弹窗(消息协议见 ./messages)。 + * + * 兜底:获取授权链接成功但 WxLogin JS 加载失败/超时时,自动回退整页跳转授权 + * (与旧流程一致);获取授权链接本身失败时在弹窗内展示错误并提供重试。 + */ +import React, { useEffect, useRef, useState } from "react" +import { Spin } from "antd" +import Modal from "@/components/ui/Modal" +import Button from "@/components/ui/Button" +import { + getWechatAuthUrl, + getWechatBindUrl, + getCurrentUser, + normalizeUser, + type User, +} from "@/api/auth" +import { useAuthStore } from "@/store/authStore" +import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh" +import { getErrorMessage } from "@/api/errors" +import { loadWxLoginScript, parseWxAuthUrl } from "@/api/auth/wxLogin" +import { isWechatQrMessage, type WechatQrScene } from "./messages" +import "./WechatQrModal.css" + +export interface WechatQrModalProps { + open: boolean + scene: WechatQrScene + onClose: () => void + /** 登录场景成功回调(needOnboarding=true 时调用方应跳昵称引导页) */ + onLoginSuccess?: (needOnboarding: boolean) => void + /** 绑定场景成功回调(调用方刷新用户信息/提示) */ + onBindSuccess?: () => void +} + +type QrStatus = "loading" | "qrcode" | "error" + +const CONTAINER_ID: Record = { + login: "wechat-qr-login-container", + bind: "wechat-qr-bind-container", +} + +const STATE_STORAGE_KEY: Record = { + login: "wechat_state", + bind: "wechat_bind_state", +} + +/** + * 等待二维码容器挂载到 DOM。antd Modal 内容通过 portal 渲染且带进场动画, + * 父组件 effect 首次执行时容器可能尚未出现在 document 中。 + */ +function waitForContainer(id: string, timeoutMs = 3000): Promise { + return new Promise((resolve) => { + const start = Date.now() + const check = () => { + const el = document.getElementById(id) + if (el) { + resolve(el) + return + } + if (Date.now() - start > timeoutMs) { + resolve(null) + return + } + setTimeout(check, 50) + } + check() + }) +} + +const WechatQrModal: React.FC = ({ + open, + scene, + onClose, + onLoginSuccess, + onBindSuccess, +}) => { + const setAuth = useAuthStore((state) => state.setAuth) + const setUser = useAuthStore((state) => state.setUser) + const [status, setStatus] = useState("loading") + const [errorMsg, setErrorMsg] = useState("") + /** 刷新二维码计数:变化时重新请求授权链接并重渲染 */ + const [renderSeq, setRenderSeq] = useState(0) + /** 最新授权链接,用于"整页打开"兜底 */ + const authUrlRef = useRef(null) + + const isLogin = scene === "login" + + // 初始化:获取授权链接 → 加载 WxLogin JS → 内嵌渲染二维码 + useEffect(() => { + if (!open) return + let cancelled = false + authUrlRef.current = null + setStatus("loading") + setErrorMsg("") + + const init = async () => { + try { + const fetchUrl = isLogin ? getWechatAuthUrl : getWechatBindUrl + const result = await fetchUrl() + if (cancelled) return + // 写 state(整页跳转兜底路径的回调页也会清理它) + localStorage.setItem(STATE_STORAGE_KEY[scene], result.state) + authUrlRef.current = result.auth_url + + const params = parseWxAuthUrl(result.auth_url, result.state) + if (!params) { + // 授权链接格式异常:直接整页跳转,由微信侧/回调页兜底 + window.location.href = result.auth_url + return + } + + const WxLogin = await loadWxLoginScript() + if (cancelled) return + // 等 Modal portal 中的容器挂载完成 + const container = await waitForContainer(CONTAINER_ID[scene]) + if (cancelled) return + if (!container) { + window.location.href = result.auth_url + return + } + container.innerHTML = "" + new WxLogin({ + self_redirect: true, + id: CONTAINER_ID[scene], + appid: params.appid, + scope: "snsapi_login", + redirect_uri: params.redirect_uri, + state: params.state, + style: "black", + }) + if (!cancelled) setStatus("qrcode") + } catch (err) { + if (cancelled) return + if (authUrlRef.current) { + // 授权链接已拿到但二维码脚本加载失败/超时:回退整页跳转 + window.location.href = authUrlRef.current + return + } + // 授权链接接口本身失败:弹窗内展示真实原因,允许重试 + setErrorMsg(getErrorMessage(err, "微信服务暂不可用,请稍后重试")) + setStatus("error") + } + } + + init() + return () => { + cancelled = true + } + }, [open, scene, isLogin, renderSeq]) + + // 监听 iframe 内回调页 postMessage 回来的扫码结果 + useEffect(() => { + if (!open) return + + const handleMessage = async (event: MessageEvent) => { + // 只接受同源消息 + if (event.origin !== window.location.origin) return + if (!isWechatQrMessage(event.data, scene)) return + const msg = event.data + + if (msg.success) { + if (isLogin) { + // iframe 内回调页已把 token 写入 localStorage(同源共享), + // 父窗口同步内存登录态后交给调用方跳转 + try { + const userData = await getCurrentUser() + const user = normalizeUser(userData) as User + setAuth( + user, + localStorage.getItem("access_token") || "", + localStorage.getItem("refresh_token"), + ) + scheduleProactiveRefresh() + } catch { + // token 已持久化,即使这里失败路由守卫/刷新也能恢复登录态 + } + onLoginSuccess?.(msg.payload?.needOnboarding ?? false) + } else { + try { + const userData = await getCurrentUser() + setUser(normalizeUser(userData) as User) + } catch { + // 绑定结果以后端为准,调用方 invalidateQueries 会兜底刷新 + } + onBindSuccess?.() + } + return + } + + // 失败:弹窗内展示回调页透传的真实原因,提供刷新/整页跳转 + setErrorMsg(msg.detail || "微信授权失败,请重试") + setStatus("error") + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [open, scene, isLogin, onLoginSuccess, onBindSuccess, setAuth, setUser]) + + const handleRefresh = () => setRenderSeq((seq) => seq + 1) + + const handleFullPageRedirect = () => { + if (authUrlRef.current) { + window.location.href = authUrlRef.current + } + } + + return ( + +
+ {/* 二维码容器常驻:WxLogin 在 loading 阶段就会把 iframe 渲染进来, + 不能按 status 条件渲染,否则 effect 里永远找不到容器 */} +
+ + {status === "loading" && ( +
+ +

正在生成微信二维码...

+
+ )} + + {status === "qrcode" && ( +

请使用微信扫描二维码{isLogin ? "登录" : "绑定账号"}

+ )} + + {status === "error" && ( +
+

{errorMsg}

+
+ + {authUrlRef.current && ( + + )} +
+
+ )} +
+ + ) +} + +export default WechatQrModal diff --git a/apps/web/src/components/auth/WechatQrModal/messages.ts b/apps/web/src/components/auth/WechatQrModal/messages.ts new file mode 100644 index 000000000..365eba137 --- /dev/null +++ b/apps/web/src/components/auth/WechatQrModal/messages.ts @@ -0,0 +1,71 @@ +/** + * 微信扫码弹窗与 iframe 内回调页之间的 postMessage 消息协议 + * + * 流程:弹窗内 WxLogin(self_redirect:true) 渲染的二维码 iframe 扫码后, + * 微信重定向到本站回调页(同源,在 iframe 内加载);回调页完成换 token/绑定后, + * 通过 window.parent.postMessage 把结果通知弹窗,弹窗负责关闭/展示错误/同步登录态。 + */ + +/** 扫码场景:登录 / 绑定 */ +export type WechatQrScene = "login" | "bind" + +export interface WechatQrSuccessPayload { + /** 登录场景:是否需要昵称引导(新用户或资料未完善) */ + needOnboarding?: boolean +} + +export interface WechatQrMessageData { + /** 固定协议标识,父窗口只认该 source */ + source: "xiaoxia-wechat-qr" + /** 场景,需与弹窗发起时一致(login/bind),父窗口据此过滤 */ + scene: WechatQrScene + /** 成功 / 失败 */ + success: boolean + /** 失败时的真实原因(已在回调页拼好,含后端 detail) */ + detail?: string + payload?: WechatQrSuccessPayload +} + +export const WECHAT_QR_MESSAGE_SOURCE = "xiaoxia-wechat-qr" + +/** 判断收到的 message 是否为本协议消息(且场景匹配) */ +export function isWechatQrMessage( + data: unknown, + scene: WechatQrScene, +): data is WechatQrMessageData { + if (!data || typeof data !== "object") return false + const msg = data as Partial + return msg.source === WECHAT_QR_MESSAGE_SOURCE && msg.scene === scene +} + +/** 当前页面是否运行在 iframe(弹窗内嵌二维码)中 */ +export function isInIframe(): boolean { + try { + return window.parent !== window + } catch { + // 跨域访问 window.parent 可能抛异常,按非 iframe 处理 + return false + } +} + +/** + * iframe 内回调页向父窗口上报扫码结果。同源回调页加载,targetOrigin 限定本站 origin。 + */ +export function postWechatQrResult( + scene: WechatQrScene, + success: boolean, + options?: { detail?: string; needOnboarding?: boolean }, +): void { + if (!isInIframe()) return + const data: WechatQrMessageData = { + source: WECHAT_QR_MESSAGE_SOURCE, + scene, + success, + detail: options?.detail, + payload: + success && options?.needOnboarding !== undefined + ? { needOnboarding: options.needOnboarding } + : undefined, + } + window.parent.postMessage(data, window.location.origin) +} diff --git a/apps/web/src/pages/auth/Login.tsx b/apps/web/src/pages/auth/Login.tsx index 90acff2b2..a869eecdd 100644 --- a/apps/web/src/pages/auth/Login.tsx +++ b/apps/web/src/pages/auth/Login.tsx @@ -1,13 +1,12 @@ /** * 登录页面 - V21 完全对标 */ -import React, { useRef, useState } from "react" +import React, { useState } from "react" import { Form, Input, Checkbox, message } from "antd" import { Link, useNavigate } from "react-router-dom" import { useLogin } from "@/hooks/useAuth" -import { getWechatAuthUrl } from "@/api/auth" -import { getErrorMessage, isErrorMsgShown } from "@/api/errors" import Button from "@/components/ui/Button" +import WechatQrModal from "@/components/auth/WechatQrModal" import "./Login.css" interface LoginFormValues { @@ -20,10 +19,7 @@ const Login: React.FC = () => { const navigate = useNavigate() const loginMutation = useLogin() const [form] = Form.useForm() - const [wechatLoading, setWechatLoading] = useState(false) - // 同步防连点守卫:state 更新有渲染间隙,连点两次会各自请求授权 URL, - // 后一次的 state 覆盖前一次写入 localStorage 的 state,导致回调校验失败 - const wechatStartingRef = useRef(false) + const [wechatQrOpen, setWechatQrOpen] = useState(false) const onFinish = async (values: LoginFormValues) => { try { @@ -39,33 +35,28 @@ const Login: React.FC = () => { } } - const handleWechatLogin = async () => { - if (wechatStartingRef.current) return - wechatStartingRef.current = true - setWechatLoading(true) - try { - const result = await getWechatAuthUrl() - // 保存 state 到 localStorage 用于回调时验证 - localStorage.setItem("wechat_state", result.state) - // 记录登录前的来源页,登录成功后跳回 - const from = window.location.pathname + window.location.search - if (from !== "/login" && from !== "/register") { - localStorage.setItem("login_redirect", from) - } else { - localStorage.removeItem("login_redirect") - } - // 跳转到微信授权页 - window.location.href = result.auth_url - } catch (error) { - // 跳走前才可能回到这里;拦截器已弹过后端 detail 时不重复弹, - // 否则透传真实原因(如微信服务未配置、网络异常) - if (!isErrorMsgShown(error)) { - message.error(`微信登录启动失败:${getErrorMessage(error, "请稍后重试")}`) - } - wechatStartingRef.current = false - setWechatLoading(false) + const handleWechatLogin = () => { + // 记录登录前的来源页,登录成功后(弹窗回调)跳回 + const from = window.location.pathname + window.location.search + if (from !== "/login" && from !== "/register") { + localStorage.setItem("login_redirect", from) + } else { + localStorage.removeItem("login_redirect") } - // 成功时 window.location 跳走,不复位 loading(页面即将卸载) + setWechatQrOpen(true) + // 弹窗打开期间按钮 disabled;WxLogin 脚本加载失败/超时时弹窗内会自动回退整页跳转 + } + + // 弹窗扫码登录成功:登录态已由弹窗同步,按用户类型跳转 + const handleWechatQrSuccess = (needOnboarding: boolean) => { + setWechatQrOpen(false) + if (needOnboarding) { + navigate("/welcome/wechat", { replace: true }) + return + } + const redirect = localStorage.getItem("login_redirect") || "/" + localStorage.removeItem("login_redirect") + navigate(redirect, { replace: true }) } return ( @@ -136,10 +127,10 @@ const Login: React.FC = () => { type="button" className="xx-btn-wechat" onClick={handleWechatLogin} - disabled={wechatLoading} + disabled={wechatQrOpen} > 💬 - {wechatLoading ? "加载中..." : "微信登录"} + 微信登录
@@ -147,6 +138,13 @@ const Login: React.FC = () => { 还没有账号? 立即注册 + + setWechatQrOpen(false)} + onLoginSuccess={handleWechatQrSuccess} + /> ) } diff --git a/apps/web/src/pages/auth/WechatBindCallback.tsx b/apps/web/src/pages/auth/WechatBindCallback.tsx index 61ded7e61..641f0d4bd 100644 --- a/apps/web/src/pages/auth/WechatBindCallback.tsx +++ b/apps/web/src/pages/auth/WechatBindCallback.tsx @@ -1,6 +1,11 @@ /** * 微信绑定回调页(已登录用户在设置页发起"绑定微信"扫码后回到这里) * 用 code 调绑定接口把微信关联到当前账号,成功后回设置页 + * + * 两种运行环境: + * - 整页跳转授权(旧流程/兜底):本页整页加载,成功/失败后 navigate 回设置页 + * - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载, + * 结果通过 postMessage 通知父窗口弹窗,不做页面导航 */ import React, { useEffect, useState } from "react" import { useSearchParams, useNavigate } from "react-router-dom" @@ -8,19 +13,30 @@ import { Spin } from "antd" import { bindWechat, normalizeUser } from "@/api/auth" import { getErrorMessage } from "@/api/errors" import { useAuthStore } from "@/store/authStore" +import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages" const WechatBindCallback: React.FC = () => { const [searchParams] = useSearchParams() const navigate = useNavigate() const setUser = useAuthStore((state) => state.setUser) const [error, setError] = useState(null) + const inIframe = isInIframe() useEffect(() => { const code = searchParams.get("code") const state = searchParams.get("state") + const fail = (message: string) => { + if (inIframe) { + // 弹窗模式:把真实原因上报父窗口在 Modal 内展示 + postWechatQrResult("bind", false, { detail: message }) + return + } + setError(message) + } + if (!code || !state) { - setError("无效的回调参数,请回到设置页重新扫码绑定") + fail("无效的回调参数,请回到设置页重新扫码绑定") return } @@ -32,16 +48,23 @@ const WechatBindCallback: React.FC = () => { try { const result = await bindWechat(code, state) setUser(normalizeUser(result.user)) + + if (inIframe) { + // 弹窗模式:通知父窗口关闭弹窗并刷新绑定状态 + postWechatQrResult("bind", true) + return + } + // 用 replace 回设置页,query 携带成功标记由设置页提示 navigate("/app/profile?wechat_bind=success", { replace: true }) } catch (err) { - // 绑定失败直接在本页展示真实原因(如微信已被其他账号绑定),不静默跳走 - setError(`微信绑定失败:${getErrorMessage(err, "请回到设置页重试")}`) + // 绑定失败直接在本页展示/上报真实原因(如微信已被其他账号绑定),不静默跳走 + fail(`微信绑定失败:${getErrorMessage(err, "请回到设置页重试")}`) } } handleBind() - }, [searchParams, navigate, setUser]) + }, [searchParams, navigate, setUser, inIframe]) if (error) { return ( diff --git a/apps/web/src/pages/auth/WechatCallback.tsx b/apps/web/src/pages/auth/WechatCallback.tsx index 754a4ca70..2b7ecbaef 100644 --- a/apps/web/src/pages/auth/WechatCallback.tsx +++ b/apps/web/src/pages/auth/WechatCallback.tsx @@ -2,6 +2,11 @@ * 微信登录回调页 * 扫码授权后由微信重定向回来:用 code 换登录态, * 新用户/资料未完善 → 跳昵称引导页;老用户 → 回来源页/首页 + * + * 两种运行环境: + * - 整页跳转授权(旧流程/兜底):本页整页加载,按上述逻辑导航 + * - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载, + * 成功/失败均通过 postMessage 通知父窗口弹窗,不做页面导航 */ import React, { useEffect, useState } from "react" import { useSearchParams, useNavigate } from "react-router-dom" @@ -10,6 +15,7 @@ import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/ import { getErrorMessage } from "@/api/errors" import { useAuthStore } from "@/store/authStore" import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh" +import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages" const WechatCallback: React.FC = () => { const [searchParams] = useSearchParams() @@ -17,24 +23,33 @@ const WechatCallback: React.FC = () => { const setAuth = useAuthStore((state) => state.setAuth) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const inIframe = isInIframe() useEffect(() => { const code = searchParams.get("code") const state = searchParams.get("state") - // 微信重定向出错时(如用户拒绝授权 error=access_denied)直接展示原因 + const fail = (message: string) => { + if (inIframe) { + // 弹窗模式:把真实原因上报父窗口在 Modal 内展示,本页保持"处理中"即可 + postWechatQrResult("login", false, { detail: message }) + return + } + setError(message) + setLoading(false) + } + + // 微信重定向出错时(如用户拒绝授权 error=access_denied)直接展示/上报原因 const wxErrorCode = searchParams.get("error") const wxErrDesc = searchParams.get("error_description") if (wxErrorCode || wxErrDesc) { const reason = [wxErrorCode, wxErrDesc].filter(Boolean).join(":") - setError(`微信授权失败:${reason}`) - setLoading(false) + fail(`微信授权失败:${reason}`) return } if (!code || !state) { - setError("无效的回调参数,请重新扫码登录") - setLoading(false) + fail("无效的回调参数,请重新扫码登录") return } @@ -61,6 +76,13 @@ const WechatCallback: React.FC = () => { // 新用户 或 资料未完善(如上次中断没填昵称)→ 强制昵称引导 const needOnboarding = result.is_new_user || user.profile_completed === false + + if (inIframe) { + // 弹窗模式:token 已写入同源 localStorage,通知父窗口同步登录态并跳转 + postWechatQrResult("login", true, { needOnboarding }) + return + } + if (needOnboarding) { navigate("/welcome/wechat", { replace: true }) return @@ -72,13 +94,12 @@ const WechatCallback: React.FC = () => { navigate(redirect, { replace: true }) } catch (err) { // 透传后端真实错误(如 state 过期、code 已消费、接口异常),禁止吞成通用提示 - setError(`微信登录失败:${getErrorMessage(err, "请重试或更换登录方式")}`) - setLoading(false) + fail(`微信登录失败:${getErrorMessage(err, "请重试或更换登录方式")}`) } } handleCallback() - }, [searchParams, navigate, setAuth]) + }, [searchParams, navigate, setAuth, inIframe]) if (loading) { return ( diff --git a/apps/web/src/pages/profile/Settings.tsx b/apps/web/src/pages/profile/Settings.tsx index 7513c3bcf..6a7f766ee 100644 --- a/apps/web/src/pages/profile/Settings.tsx +++ b/apps/web/src/pages/profile/Settings.tsx @@ -8,9 +8,10 @@ import { useSearchParams } from "react-router-dom" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { message } from "antd" import { Button, Input, Modal } from "@/components/ui" -import { getCurrentUser, updateProfile, getWechatBindUrl, unbindWechat } from "@/api/auth" +import { getCurrentUser, updateProfile, unbindWechat } from "@/api/auth" import { useAuthStore } from "@/store/authStore" import PageHead from "@/components/layout/PageHead" +import WechatQrModal from "@/components/auth/WechatQrModal" import "./ProfileSettings.css" const Settings: React.FC = () => { @@ -19,6 +20,7 @@ const Settings: React.FC = () => { const queryClient = useQueryClient() const [searchParams, setSearchParams] = useSearchParams() const [displayName, setDisplayName] = useState(user?.display_name || "") + const [wechatBindOpen, setWechatBindOpen] = useState(false) const bindTipShownRef = useRef(false) // 拉取最新用户信息(微信绑定状态以后端为准) @@ -62,14 +64,11 @@ const Settings: React.FC = () => { }, }) - const handleBindWechat = async () => { - try { - const result = await getWechatBindUrl() - localStorage.setItem("wechat_bind_state", result.state) - window.location.href = result.auth_url - } catch { - message.error("微信绑定暂不可用,请稍后重试") - } + // 弹窗扫码绑定成功:关闭弹窗,刷新用户信息并提示 + const handleBindSuccess = () => { + setWechatBindOpen(false) + queryClient.invalidateQueries({ queryKey: ["currentUser"] }) + message.success("微信绑定成功") } const unbindMutation = useMutation({ @@ -177,13 +176,20 @@ const Settings: React.FC = () => { 解绑 ) : ( - )} + + setWechatBindOpen(false)} + onBindSuccess={handleBindSuccess} + /> ) } diff --git a/apps/web/src/test/api/wxLogin.test.ts b/apps/web/src/test/api/wxLogin.test.ts new file mode 100644 index 000000000..5cc38730d --- /dev/null +++ b/apps/web/src/test/api/wxLogin.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" + +describe("wxLogin 工具", () => { + describe("parseWxAuthUrl", () => { + it("从微信授权链接解析出 appid/redirect_uri/state(redirect_uri 解码)", async () => { + const { parseWxAuthUrl } = await import("@/api/auth/wxLogin") + const authUrl = + "https://open.weixin.qq.com/connect/qrconnect?appid=wxb7ae80b48e53980d" + + "&redirect_uri=https%3A%2F%2Fstaging.xiaoxiajianji.com%2Fauth%2Fwechat%2Fcallback" + + "&response_type=code&scope=snsapi_login&state=abc123#wechat_redirect" + const params = parseWxAuthUrl(authUrl) + expect(params).not.toBeNull() + expect(params?.appid).toBe("wxb7ae80b48e53980d") + expect(params?.redirect_uri).toBe("https://staging.xiaoxiajianji.com/auth/wechat/callback") + expect(params?.state).toBe("abc123") + }) + + it("链接里缺 state 时回退使用 stateFallback", async () => { + const { parseWxAuthUrl } = await import("@/api/auth/wxLogin") + const authUrl = + "https://open.weixin.qq.com/connect/qrconnect?appid=wx123" + + "&redirect_uri=https%3A%2F%2Fexample.com%2Fcb" + const params = parseWxAuthUrl(authUrl, "fallback-state") + expect(params?.state).toBe("fallback-state") + }) + + it("缺 appid 或 redirect_uri 时返回 null(调用方应回退整页跳转)", async () => { + const { parseWxAuthUrl } = await import("@/api/auth/wxLogin") + expect(parseWxAuthUrl("https://open.weixin.qq.com/connect/qrconnect?appid=wx123")).toBeNull() + expect(parseWxAuthUrl("not a url")).toBeNull() + }) + }) + + describe("loadWxLoginScript", () => { + beforeEach(() => { + vi.resetModules() + document.head.querySelectorAll("script[src*='wxLogin']").forEach((el) => el.remove()) + delete (window as unknown as { WxLogin?: unknown }).WxLogin + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it("window.WxLogin 已存在时直接复用,不重复插入 script", async () => { + const fakeCtor = vi.fn() + ;(window as unknown as { WxLogin: unknown }).WxLogin = fakeCtor + const { loadWxLoginScript } = await import("@/api/auth/wxLogin") + const ctor = await loadWxLoginScript() + expect(ctor).toBe(fakeCtor) + expect(document.head.querySelector("script[src*='wxLogin']")).toBeNull() + }) + + it("脚本 onerror 时 reject(调用方据此回退整页跳转)", async () => { + const { loadWxLoginScript } = await import("@/api/auth/wxLogin") + const promise = loadWxLoginScript() + const script = document.head.querySelector( + "script[src*='wxLogin']", + ) as HTMLScriptElement | null + expect(script).not.toBeNull() + script?.dispatchEvent(new Event("error")) + await expect(promise).rejects.toThrow(/加载失败/) + }) + }) +}) diff --git a/apps/web/src/test/components/WechatQrModal.test.tsx b/apps/web/src/test/components/WechatQrModal.test.tsx new file mode 100644 index 000000000..c3910e0d5 --- /dev/null +++ b/apps/web/src/test/components/WechatQrModal.test.tsx @@ -0,0 +1,165 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" +import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react" +import WechatQrModal from "@/components/auth/WechatQrModal" + +const { mockWxLoginCtor, mockGetAuthUrl, mockGetBindUrl, mockGetCurrentUser } = vi.hoisted(() => ({ + mockWxLoginCtor: vi.fn(), + mockGetAuthUrl: vi.fn(), + mockGetBindUrl: vi.fn(), + mockGetCurrentUser: vi.fn(), +})) + +vi.mock("@/api/auth", () => ({ + getWechatAuthUrl: (...args: unknown[]) => mockGetAuthUrl(...args), + getWechatBindUrl: (...args: unknown[]) => mockGetBindUrl(...args), + getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args), + normalizeUser: (u: unknown) => u, +})) + +vi.mock("@/api/auth/wxLogin", () => ({ + loadWxLoginScript: vi.fn(async () => mockWxLoginCtor), + parseWxAuthUrl: vi.fn(() => ({ + appid: "wxb7ae80b48e53980d", + redirect_uri: "https://staging.xiaoxiajianji.com/auth/wechat/callback", + state: "state-from-url", + })), +})) + +vi.mock("@/api/auth/tokenRefresh", () => ({ + scheduleProactiveRefresh: vi.fn(), + cancelProactiveRefresh: vi.fn(), +})) + +const { mockSetAuth, mockSetUser } = vi.hoisted(() => ({ + mockSetAuth: vi.fn(), + mockSetUser: vi.fn(), +})) +vi.mock("@/store/authStore", () => ({ + useAuthStore: (selector: (s: unknown) => unknown) => + selector({ setAuth: mockSetAuth, setUser: mockSetUser }), +})) + +const AUTH_URL = + "https://open.weixin.qq.com/connect/qrconnect?appid=wxb7ae80b48e53980d" + + "&redirect_uri=https%3A%2F%2Fstaging.xiaoxiajianji.com%2Fauth%2Fwechat%2Fcallback&state=st123" + +const postMessage = (data: Record) => + window.dispatchEvent(new MessageEvent("message", { data, origin: window.location.origin })) + +beforeEach(() => { + vi.clearAllMocks() + mockGetAuthUrl.mockResolvedValue({ auth_url: AUTH_URL, state: "st123" }) + mockGetBindUrl.mockResolvedValue({ auth_url: AUTH_URL, state: "st123" }) + mockGetCurrentUser.mockResolvedValue({ id: 1, display_name: "测试用户" }) + localStorage.clear() +}) + +afterEach(() => cleanup()) + +describe("WechatQrModal", () => { + it("open=false 时不渲染弹窗内容", () => { + render() + expect(screen.queryByText("微信扫码登录")).toBeNull() + }) + + it("登录场景:open 后请求授权链接、写入 state、用 WxLogin 渲染二维码", async () => { + render() + await waitFor(() => expect(mockGetAuthUrl).toHaveBeenCalledTimes(1)) + expect(localStorage.getItem("wechat_state")).toBe("st123") + await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1)) + expect(mockWxLoginCtor).toHaveBeenCalledWith( + expect.objectContaining({ + self_redirect: true, + appid: "wxb7ae80b48e53980d", + scope: "snsapi_login", + state: "state-from-url", + redirect_uri: "https://staging.xiaoxiajianji.com/auth/wechat/callback", + }), + ) + expect(screen.getByText(/请使用微信扫描二维码登录/)).toBeTruthy() + }) + + it("绑定场景:请求 bind/url 且写入 wechat_bind_state", async () => { + render() + await waitFor(() => expect(mockGetBindUrl).toHaveBeenCalledTimes(1)) + expect(mockGetAuthUrl).not.toHaveBeenCalled() + expect(localStorage.getItem("wechat_bind_state")).toBe("st123") + await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1)) + }) + + it("获取授权链接失败时弹窗内展示错误并提供刷新", async () => { + mockGetAuthUrl.mockRejectedValueOnce({ + response: { status: 500, data: { detail: "微信服务内部错误" } }, + }) + render() + expect(await screen.findByText(/微信服务内部错误/)).toBeTruthy() + expect(screen.getByText("刷新二维码")).toBeTruthy() + // 点刷新后重新请求 + fireEvent.click(screen.getByText("刷新二维码")) + await waitFor(() => expect(mockGetAuthUrl).toHaveBeenCalledTimes(2)) + }) + + it("登录成功消息:同步登录态并回调 onLoginSuccess(needOnboarding)", async () => { + const onSuccess = vi.fn() + localStorage.setItem("access_token", "tok-123") + render() + await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1)) + + postMessage({ + source: "xiaoxia-wechat-qr", + scene: "login", + success: true, + payload: { needOnboarding: true }, + }) + + await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(true)) + expect(mockGetCurrentUser).toHaveBeenCalled() + expect(mockSetAuth).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), "tok-123", null) + }) + + it("登录失败消息:弹窗内展示回调页透传的真实原因", async () => { + render() + await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1)) + + postMessage({ + source: "xiaoxia-wechat-qr", + scene: "login", + success: false, + detail: "微信登录失败:state 已过期或已被使用", + }) + + expect(await screen.findByText(/state 已过期或已被使用/)).toBeTruthy() + }) + + it("绑定成功消息:刷新用户并回调 onBindSuccess", async () => { + const onBindSuccess = vi.fn() + render() + await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1)) + + postMessage({ source: "xiaoxia-wechat-qr", scene: "bind", success: true }) + + await waitFor(() => expect(onBindSuccess).toHaveBeenCalledTimes(1)) + expect(mockSetUser).toHaveBeenCalled() + }) + + it("忽略跨源消息和其他场景的消息", async () => { + const onSuccess = vi.fn() + render() + await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1)) + + // 跨源 + window.dispatchEvent( + new MessageEvent("message", { + data: { source: "xiaoxia-wechat-qr", scene: "login", success: true }, + origin: "https://evil.example.com", + }), + ) + // 场景不符(bind 消息发给 login 弹窗) + postMessage({ source: "xiaoxia-wechat-qr", scene: "bind", success: true }) + // 无协议标识 + postMessage({ foo: "bar" }) + + await new Promise((r) => setTimeout(r, 50)) + expect(onSuccess).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/test/pages/auth/WechatBindCallback.test.tsx b/apps/web/src/test/pages/auth/WechatBindCallback.test.tsx index a2592fc2f..c9cadc752 100644 --- a/apps/web/src/test/pages/auth/WechatBindCallback.test.tsx +++ b/apps/web/src/test/pages/auth/WechatBindCallback.test.tsx @@ -41,6 +41,16 @@ vi.mock("@/store/authStore", () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ setUser: mockSetUser }), })) +// iframe 场景:默认非 iframe;用例可 mockReturnValue(true) +const { mockIsInIframe, mockPostResult } = vi.hoisted(() => ({ + mockIsInIframe: vi.fn(() => false), + mockPostResult: vi.fn(), +})) +vi.mock("@/components/auth/WechatQrModal/messages", () => ({ + isInIframe: () => mockIsInIframe(), + postWechatQrResult: (...args: unknown[]) => mockPostResult(...args), +})) + const renderPage = () => render( @@ -55,6 +65,7 @@ describe("WechatBindCallback Page", () => { beforeEach(() => { vi.clearAllMocks() + mockIsInIframe.mockReturnValue(false) bindError = null Array.from(mockParams.keys()).forEach((k) => mockParams.delete(k)) mockParams.set("code", "bind_code") @@ -102,4 +113,32 @@ describe("WechatBindCallback Page", () => { expect(screen.getByText(/无效的回调参数/)).toBeTruthy() }) }) + + describe("iframe(弹窗内嵌二维码)场景", () => { + it("绑定成功时 postMessage 通知父窗口,不做 navigate", async () => { + mockIsInIframe.mockReturnValue(true) + renderPage() + await waitFor(() => { + expect(mockPostResult).toHaveBeenCalledWith("bind", true) + }) + expect(mockSetUser).toHaveBeenCalled() + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("绑定失败时把真实原因 postMessage 给父窗口", async () => { + mockIsInIframe.mockReturnValue(true) + bindError = { + isAxiosError: true, + response: { status: 409, data: { detail: "该微信已绑定其他账号" } }, + } + renderPage() + await waitFor(() => { + expect(mockPostResult).toHaveBeenCalledWith("bind", false, { + detail: expect.stringContaining("该微信已绑定其他账号"), + }) + }) + expect(screen.queryByText(/返回设置/)).toBeNull() + expect(mockNavigate).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/web/src/test/pages/auth/WechatCallback.test.tsx b/apps/web/src/test/pages/auth/WechatCallback.test.tsx index 5ef33e4c2..7aa16f3c4 100644 --- a/apps/web/src/test/pages/auth/WechatCallback.test.tsx +++ b/apps/web/src/test/pages/auth/WechatCallback.test.tsx @@ -53,6 +53,16 @@ vi.mock("@/store/authStore", () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ setAuth: mockSetAuth }), })) +// iframe 场景:默认非 iframe;用例可 mockReturnValue(true) +const { mockIsInIframe, mockPostResult } = vi.hoisted(() => ({ + mockIsInIframe: vi.fn(() => false), + mockPostResult: vi.fn(), +})) +vi.mock("@/components/auth/WechatQrModal/messages", () => ({ + isInIframe: () => mockIsInIframe(), + postWechatQrResult: (...args: unknown[]) => mockPostResult(...args), +})) + const renderPage = () => render( @@ -67,6 +77,7 @@ describe("WechatCallback Page", () => { beforeEach(() => { vi.clearAllMocks() + mockIsInIframe.mockReturnValue(false) callbackError = null // 默认正常回调参数;用例可改写 mockParams 模拟 error 重定向 Array.from(mockParams.keys()).forEach((k) => mockParams.delete(k)) @@ -159,4 +170,54 @@ describe("WechatCallback Page", () => { renderPage() expect(screen.getByText("微信登录中...")).toBeTruthy() }) + + describe("iframe(弹窗内嵌二维码)场景", () => { + it("登录成功时 postMessage 通知父窗口(needOnboarding=false),不做 navigate", async () => { + mockIsInIframe.mockReturnValue(true) + renderPage() + await waitFor(() => { + expect(mockPostResult).toHaveBeenCalledWith("login", true, { needOnboarding: false }) + }) + expect(mockSetAuth).toHaveBeenCalled() + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("新用户成功时上报 needOnboarding=true", async () => { + mockIsInIframe.mockReturnValue(true) + mockCallbackResult = { access_token: "at", refresh_token: "rt", is_new_user: true } + renderPage() + await waitFor(() => { + expect(mockPostResult).toHaveBeenCalledWith("login", true, { needOnboarding: true }) + }) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("后端报错时把真实原因 postMessage 给父窗口,页面不渲染错误/按钮", async () => { + mockIsInIframe.mockReturnValue(true) + callbackError = { + isAxiosError: true, + response: { status: 400, data: { detail: "state 已过期或已被使用" } }, + } + renderPage() + await waitFor(() => { + expect(mockPostResult).toHaveBeenCalledWith("login", false, { + detail: expect.stringContaining("state 已过期或已被使用"), + }) + }) + expect(screen.queryByText(/返回登录/)).toBeNull() + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("微信重定向 error(拒绝授权)在 iframe 内也上报父窗口", async () => { + mockIsInIframe.mockReturnValue(true) + for (const k of Array.from(mockParams.keys())) mockParams.delete(k) + mockParams.set("error", "access_denied") + renderPage() + await waitFor(() => { + expect(mockPostResult).toHaveBeenCalledWith("login", false, { + detail: expect.stringContaining("access_denied"), + }) + }) + }) + }) }) -- 2.54.0