feat: 实现飞书环境 token 过期自动刷新
- 当 API 返回 401 时,在飞书环境中自动调用免登刷新 token - 刷新成功后自动重试原始请求,用户无感知 - 飞书外环境仍跳转到登录页 - 防止死循环:请求只重试一次 - 登录页支持从 redirect 参数中提取 company 参数 流程: 1. API 返回 401 2. 飞书环境:调用 tt.requestAuthCode 获取新授权码 3. 用新授权码登录获取新 token 4. 用新 token 重试原始请求 5. 失败或非飞书环境:跳转登录页
This commit is contained in:
@@ -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) {
|
||||
|
||||
+214
-26
@@ -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<string> => {
|
||||
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<void>((resolve) => {
|
||||
window.h5sdk.ready!(resolve)
|
||||
})
|
||||
} else {
|
||||
// 轮询等待 tt 对象
|
||||
await new Promise<void>((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<void> => {
|
||||
// 如果正在刷新 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('登录已过期'))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理其他错误
|
||||
|
||||
Reference in New Issue
Block a user