From 7c6a8fc24709e849ecec9b5752f7f7d219cc4d02 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 28 Feb 2026 11:12:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=A3=9E=E4=B9=A6?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=20token=20=E8=BF=87=E6=9C=9F=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 当 API 返回 401 时,在飞书环境中自动调用免登刷新 token - 刷新成功后自动重试原始请求,用户无感知 - 飞书外环境仍跳转到登录页 - 防止死循环:请求只重试一次 - 登录页支持从 redirect 参数中提取 company 参数 流程: 1. API 返回 401 2. 飞书环境:调用 tt.requestAuthCode 获取新授权码 3. 用新授权码登录获取新 token 4. 用新 token 重试原始请求 5. 失败或非飞书环境:跳转登录页 --- frontend/src/pages/login/index.tsx | 19 ++- frontend/src/services/api.ts | 240 +++++++++++++++++++++++++---- 2 files changed, 232 insertions(+), 27 deletions(-) diff --git a/frontend/src/pages/login/index.tsx b/frontend/src/pages/login/index.tsx index 58026b9..f84827d 100644 --- a/frontend/src/pages/login/index.tsx +++ b/frontend/src/pages/login/index.tsx @@ -57,7 +57,24 @@ export default function Login() { const isInFeishu = ua.includes('Lark') || ua.includes('feishu') || ua.includes('lark') const urlParams = new URLSearchParams(window.location.search) const authCode = urlParams.get('auth_code') - const companyParam = urlParams.get('company') + let companyParam = urlParams.get('company') + + // 如果当前 URL 没有 company,尝试从 redirect 参数中提取 + // 这处理了 token 过期从其他页面跳转过来的场景 + if (!companyParam) { + const redirect = urlParams.get('redirect') + if (redirect) { + try { + const redirectUrl = new URL(redirect, window.location.origin) + companyParam = redirectUrl.searchParams.get('company') + console.log('🔍 从 redirect URL 中提取的 company 参数:', companyParam) + } catch (e) { + console.log('⚠️ 无法解析 redirect URL:', redirect) + } + } + } + + console.log('🔍 最终使用的 company 参数:', companyParam) // 如果 URL 中有授权码,直接处理登录 if (authCode) { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6353fb3..92935f7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,39 +1,205 @@ import axios from 'axios' import { Toast } from 'antd-mobile' +// 声明飞书 JSAPI 类型 +declare global { + interface Window { + tt?: any + h5sdk?: { + ready: (callback: () => void) => void + } + } +} + const API_BASE = '/api/v1' +// 标记是否正在刷新 token +let isRefreshing = false +let failedQueue: Array<{ + resolve: (value: any) => void + reject: (reason?: any) => void +}> = [] + +// 处理队列中的请求 +const processQueue = (error: any, token: string | null = null) => { + failedQueue.forEach(({ resolve, reject }) => { + if (error) { + reject(error) + } else { + resolve(token) + } + }) + failedQueue = [] +} + +// 飞书免登获取新 token +const refreshFeishuToken = async (): Promise => { + console.log('🔄 尝试飞书免登刷新 token...') + + // 检查是否在飞书环境中 + const ua = navigator.userAgent + const isInFeishu = ua.includes('Lark') || ua.includes('feishu') || ua.includes('lark') + if (!isInFeishu) { + throw new Error('NOT_IN_FEISHU') + } + + // 等待 tt 对象 + if (!window.tt) { + // 如果有 h5sdk,等待 ready + if (window.h5sdk?.ready) { + await new Promise((resolve) => { + window.h5sdk.ready!(resolve) + }) + } else { + // 轮询等待 tt 对象 + await new Promise((resolve, reject) => { + let attempts = 0 + const maxAttempts = 50 + const checkInterval = setInterval(() => { + attempts++ + if (window.tt) { + clearInterval(checkInterval) + resolve() + } else if (attempts >= maxAttempts) { + clearInterval(checkInterval) + reject(new Error('TT_OBJECT_TIMEOUT')) + } + }, 100) + }) + } + } + + // 获取 company 参数(从当前 URL 或 localStorage) + const urlParams = new URLSearchParams(window.location.search) + let companyParam = urlParams.get('company') + if (!companyParam) { + // 尝试从用户信息中获取 company_id + const userStorage = localStorage.getItem('user-storage') + if (userStorage) { + try { + const user = JSON.parse(userStorage) + companyParam = user.state?.user?.company_id?.toString() + } catch (e) { + console.error('解析用户信息失败:', e) + } + } + } + + // 获取 appId + let appId = urlParams.get('app_id') + if (!appId && companyParam) { + try { + const response = await fetch(`/api/v1/auth/feishu-config?company=${companyParam}`) + const result = await response.json() + if (result.code === 0 && result.data.app_id) { + appId = result.data.app_id + } + } catch (e) { + console.error('获取 appId 失败:', e) + } + } + + if (!appId) { + throw new Error('NO_APP_ID') + } + + // 调用飞书免登 + return new Promise((resolve, reject) => { + const redirectUri = `${window.location.origin}/login/callback` + window.tt.requestAuthCode({ + appId: appId, + redirectUri: redirectUri, + success: async (res: any) => { + try { + // 用新授权码登录 + const loginRes = await fetch('/api/v1/auth/feishu-login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + auth_code: res.code, + company: companyParam || undefined, + }), + }) + const loginResult = await loginRes.json() + + if (loginResult.code === 0 && loginResult.data.token) { + const newToken = loginResult.data.token + // 更新本地存储 + localStorage.setItem('token', newToken) + // 更新用户 store + if (loginResult.data.user) { + const { useUserStore } = await import('@/stores/user') + const { login } = useUserStore.getState() + login(loginResult.data.user, newToken) + } + console.log('✅ Token 刷新成功') + resolve(newToken) + } else { + reject(new Error(loginResult.message || '登录失败')) + } + } catch (e) { + reject(e) + } + }, + fail: (error: any) => { + reject(new Error(error.errMsg || '免登失败')) + }, + }) + }) +} + // 标记是否正在处理登出(防止重复跳转) let isLoggingOut = false // 处理 401 未授权错误 -const handleUnauthorized = () => { - if (isLoggingOut) return - isLoggingOut = true +const handleUnauthorized = async (): Promise => { + // 如果正在刷新 token,将请求加入队列 + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }) + }) + } - // 保存当前页面信息,用于登录后跳转回来 - const currentUrl = window.location.pathname + window.location.search - console.log('🚨 handleUnauthorized 被调用, currentUrl:', currentUrl) - sessionStorage.setItem('redirect_after_login', currentUrl) + isRefreshing = true - // 清除本地存储 - localStorage.removeItem('token') - localStorage.removeItem('user-storage') + try { + // 尝试飞书免登刷新 token + const newToken = await refreshFeishuToken() + isRefreshing = false + processQueue(null, newToken) + return + } catch (error: any) { + isRefreshing = false + processQueue(error, null) - // 显示提示 - Toast.show({ - content: '请先登录', - duration: 1500, - }) + // 飞书免登失败,执行原有跳转逻辑 + console.error('飞书免登刷新失败:', error.message) - // 延迟跳转,确保提示显示 - setTimeout(() => { - isLoggingOut = false - const redirectUrl = '/login?redirect=' + encodeURIComponent(currentUrl) - console.log('🚨 准备跳转到登录页, redirectUrl:', redirectUrl) - // 跳转到登录页,带上 redirect 参数 - window.location.href = redirectUrl - }, 500) + // 如果是非飞书环境或免登失败,跳转到登录页 + if (isLoggingOut) return + isLoggingOut = true + + const currentUrl = window.location.pathname + window.location.search + console.log('🚨 跳转到登录页, currentUrl:', currentUrl) + sessionStorage.setItem('redirect_after_login', currentUrl) + + // 清除本地存储 + localStorage.removeItem('token') + localStorage.removeItem('user-storage') + + Toast.show({ + content: '请先登录', + duration: 1500, + }) + + setTimeout(() => { + isLoggingOut = false + const redirectUrl = '/login?redirect=' + encodeURIComponent(currentUrl) + window.location.href = redirectUrl + }, 500) + + throw error + } } // 创建 axios 实例 @@ -71,11 +237,33 @@ api.interceptors.response.use( } return Promise.reject(new Error(data.message || '请求失败')) }, - (error) => { + async (error) => { + const originalRequest = error.config + // 处理 401 未授权错误 if (error.response?.status === 401 || error.response?.data?.code === 401) { - handleUnauthorized() - return Promise.reject(new Error('登录已过期')) + // 防止死循环:如果已经重试过一次,不再重试 + if (originalRequest._retry) { + console.error('⚠️ 请求重试后仍然 401,可能需要重新登录') + // 清除 token 并跳转登录页 + localStorage.removeItem('token') + window.location.href = '/login' + return Promise.reject(new Error('登录已过期')) + } + + try { + // 尝试飞书免登刷新 token + await handleUnauthorized() + // token 刷新成功,重试原始请求 + originalRequest._retry = true + // 更新请求头中的 token + originalRequest.headers.Authorization = `Bearer ${localStorage.getItem('token')}` + console.log('🔄 使用新 token 重试请求:', originalRequest.url) + return api.request(originalRequest) + } catch (e) { + // handleUnauthorized 内部已经处理了跳转到登录页 + return Promise.reject(new Error('登录已过期')) + } } // 处理其他错误