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 isInFeishu = ua.includes('Lark') || ua.includes('feishu') || ua.includes('lark')
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const authCode = urlParams.get('auth_code')
|
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 中有授权码,直接处理登录
|
// 如果 URL 中有授权码,直接处理登录
|
||||||
if (authCode) {
|
if (authCode) {
|
||||||
|
|||||||
@@ -1,39 +1,205 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { Toast } from 'antd-mobile'
|
import { Toast } from 'antd-mobile'
|
||||||
|
|
||||||
|
// 声明飞书 JSAPI 类型
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
tt?: any
|
||||||
|
h5sdk?: {
|
||||||
|
ready: (callback: () => void) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const API_BASE = '/api/v1'
|
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
|
let isLoggingOut = false
|
||||||
|
|
||||||
// 处理 401 未授权错误
|
// 处理 401 未授权错误
|
||||||
const handleUnauthorized = () => {
|
const handleUnauthorized = async (): Promise<void> => {
|
||||||
|
// 如果正在刷新 token,将请求加入队列
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试飞书免登刷新 token
|
||||||
|
const newToken = await refreshFeishuToken()
|
||||||
|
isRefreshing = false
|
||||||
|
processQueue(null, newToken)
|
||||||
|
return
|
||||||
|
} catch (error: any) {
|
||||||
|
isRefreshing = false
|
||||||
|
processQueue(error, null)
|
||||||
|
|
||||||
|
// 飞书免登失败,执行原有跳转逻辑
|
||||||
|
console.error('飞书免登刷新失败:', error.message)
|
||||||
|
|
||||||
|
// 如果是非飞书环境或免登失败,跳转到登录页
|
||||||
if (isLoggingOut) return
|
if (isLoggingOut) return
|
||||||
isLoggingOut = true
|
isLoggingOut = true
|
||||||
|
|
||||||
// 保存当前页面信息,用于登录后跳转回来
|
|
||||||
const currentUrl = window.location.pathname + window.location.search
|
const currentUrl = window.location.pathname + window.location.search
|
||||||
console.log('🚨 handleUnauthorized 被调用, currentUrl:', currentUrl)
|
console.log('🚨 跳转到登录页, currentUrl:', currentUrl)
|
||||||
sessionStorage.setItem('redirect_after_login', currentUrl)
|
sessionStorage.setItem('redirect_after_login', currentUrl)
|
||||||
|
|
||||||
// 清除本地存储
|
// 清除本地存储
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
localStorage.removeItem('user-storage')
|
localStorage.removeItem('user-storage')
|
||||||
|
|
||||||
// 显示提示
|
|
||||||
Toast.show({
|
Toast.show({
|
||||||
content: '请先登录',
|
content: '请先登录',
|
||||||
duration: 1500,
|
duration: 1500,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 延迟跳转,确保提示显示
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isLoggingOut = false
|
isLoggingOut = false
|
||||||
const redirectUrl = '/login?redirect=' + encodeURIComponent(currentUrl)
|
const redirectUrl = '/login?redirect=' + encodeURIComponent(currentUrl)
|
||||||
console.log('🚨 准备跳转到登录页, redirectUrl:', redirectUrl)
|
|
||||||
// 跳转到登录页,带上 redirect 参数
|
|
||||||
window.location.href = redirectUrl
|
window.location.href = redirectUrl
|
||||||
}, 500)
|
}, 500)
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建 axios 实例
|
// 创建 axios 实例
|
||||||
@@ -71,13 +237,35 @@ api.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
return Promise.reject(new Error(data.message || '请求失败'))
|
return Promise.reject(new Error(data.message || '请求失败'))
|
||||||
},
|
},
|
||||||
(error) => {
|
async (error) => {
|
||||||
|
const originalRequest = error.config
|
||||||
|
|
||||||
// 处理 401 未授权错误
|
// 处理 401 未授权错误
|
||||||
if (error.response?.status === 401 || error.response?.data?.code === 401) {
|
if (error.response?.status === 401 || error.response?.data?.code === 401) {
|
||||||
handleUnauthorized()
|
// 防止死循环:如果已经重试过一次,不再重试
|
||||||
|
if (originalRequest._retry) {
|
||||||
|
console.error('⚠️ 请求重试后仍然 401,可能需要重新登录')
|
||||||
|
// 清除 token 并跳转登录页
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
window.location.href = '/login'
|
||||||
return Promise.reject(new Error('登录已过期'))
|
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('登录已过期'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理其他错误
|
// 处理其他错误
|
||||||
const message = error.response?.data?.message || error.message || '网络错误'
|
const message = error.response?.data?.message || error.message || '网络错误'
|
||||||
return Promise.reject(new Error(message))
|
return Promise.reject(new Error(message))
|
||||||
|
|||||||
Reference in New Issue
Block a user