diff --git a/frontend/api/axios/client.js b/frontend/api/axios/client.js index 79ba755ee6..93e60c6bf9 100644 --- a/frontend/api/axios/client.js +++ b/frontend/api/axios/client.js @@ -24,8 +24,7 @@ const WHITE_LIST = [ * 检查 URL 是否在白名单中 */ function isInWhiteList(url) { - if (!url) return false - return WHITE_LIST.some((path) => url.includes(path)) + return !!(url && WHITE_LIST.some(path => url.includes(path))) } /** @@ -59,23 +58,34 @@ function onRefreshed() { * 注意:只做清理工作,不处理重定向(重定向由上层回调处理) */ let isHandling401 = false +let handling401Timeout = null + function handle401Error(error) { - if (isHandling401) return - - isHandling401 = true - - try { - // 清空token - tokenManager.clearTokens() - console.warn('Token已清空,因401错误') - } catch (e) { - console.error('清空 token 失败:', e) + // 防止重复处理 + if (isHandling401 || error?._handled) { + return } - // 延迟重置标志 - setTimeout(() => { + isHandling401 = true + error && (error._handled = true) + + try { + tokenManager.clearTokens() + const message = error?.needRelogin + ? '[HTTP] Token已清空,refreshToken无效需要重新登录' + : '[HTTP] Token已清空,因401错误' + console.warn(message) + } catch (e) { + console.error('[HTTP] 清空 token 失败:', e) + } + + if (handling401Timeout) { + clearTimeout(handling401Timeout) + } + + handling401Timeout = setTimeout(() => { isHandling401 = false - }, 2000) + }, 3000) } /** @@ -117,66 +127,71 @@ export function createClientAxios(options = {}) { // 检查是否需要认证 const needToken = config.headers?.isToken !== false && !isInWhiteList(config.url || '') - if (needToken) { - // 检查 token 是否即将过期(30秒缓冲) - const BUFFER_TIME = 30 * 1000 - const currentToken = tokenManager.getAccessToken() + if (!needToken) { + return config + } - if (!currentToken) { - console.warn('[Token] 没有可用的 accessToken') + // 检查 token 是否即将过期(30秒缓冲) + const BUFFER_TIME = 30 * 1000 + const currentToken = tokenManager.getAccessToken() + + if (!currentToken) { + console.warn('[Token] 没有可用的 accessToken') + return config + } + + const isTokenExpired = tokenManager.isExpired(BUFFER_TIME) + + if (!isTokenExpired) { + // Token 未过期,直接添加 Authorization 头 + const authHeader = tokenManager.getAuthHeader() + if (authHeader) { + config.headers.Authorization = authHeader + } + return config + } + + // Token 即将过期,需要刷新 + console.info('[Token] Token刷新') + + // 如果不在刷新过程中,启动刷新 + if (!isRefreshing) { + isRefreshing = true + + if (!refreshTokenFn || typeof refreshTokenFn !== 'function') { + console.warn('[Token] 未提供刷新函数,跳过刷新') + isRefreshing = false + onRefreshed() return config } - const isTokenExpired = tokenManager.isExpired(BUFFER_TIME) - - if (isTokenExpired) { - console.info('[Token] Token刷新') - - // 如果不在刷新过程中,启动刷新 - if (!isRefreshing) { - isRefreshing = true - - // 执行刷新(使用上层传入的刷新函数) - if (refreshTokenFn && typeof refreshTokenFn === 'function') { - refreshTokenFn() - .then(() => { - console.info('[Token] 刷新成功') - isRefreshing = false - onRefreshed() - }) - .catch((error) => { - isRefreshing = false - onRefreshed() - console.error('[Token] 刷新失败:', error.message) - }) - } else { - console.warn('[Token] 未提供刷新函数,跳过刷新') - isRefreshing = false - onRefreshed() - } - } - - // 等待刷新完成 - return new Promise((resolve) => { - subscribeTokenRefresh(() => { - // 刷新完成后,重新获取 token 并添加到请求头 - const authHeader = tokenManager.getAuthHeader() - if (authHeader) { - config.headers.Authorization = authHeader - } - resolve(config) - }) + refreshTokenFn() + .then(() => { + console.info('[Token] 刷新成功') + isRefreshing = false + onRefreshed() }) - } else { - // Token 未过期,直接添加 Authorization 头 + .catch((error) => { + isRefreshing = false + onRefreshed() + console.error('[Token] 刷新失败:', error.message) + + if (error.code === 401 && error.needRelogin) { + console.info('[Token] refreshToken无效,需要重新登录') + } + }) + } + + // 等待刷新完成 + return new Promise((resolve) => { + subscribeTokenRefresh(() => { const authHeader = tokenManager.getAuthHeader() if (authHeader) { config.headers.Authorization = authHeader } - } - } - - return config + resolve(config) + }) + }) }) // 响应拦截器 @@ -195,31 +210,24 @@ export function createClientAxios(options = {}) { error.code = data?.code error.data = data - // 业务码 401/403 只在响应拦截器处理,避免重复处理 - if (data.code === 401 && typeof on401 === 'function') { - on401(error) - } - + // 业务码 403 只在响应拦截器处理,避免重复处理 if (data.code === 403 && typeof on403 === 'function') { - // 使用通用的权限不足错误,不使用后端返回的 message const forbiddenError = new Error('权限不足,无法访问该资源') forbiddenError.code = 403 on403(forbiddenError) } - // 抛出错误,业务代码可以捕获 return Promise.reject(error) } return data }, (error) => { - // HTTP 状态码 401/403 只在错误拦截器处理 - if (error.response?.status === 401 && typeof on401 === 'function') { - on401(error) - } + const status = error.response?.status - if (error.response?.status === 403 && typeof on403 === 'function') { + if (status === 401 && typeof on401 === 'function') { + on401(error) + } else if (status === 403 && typeof on403 === 'function') { const forbiddenError = new Error('权限不足,无法访问该资源') forbiddenError.code = 403 on403(forbiddenError) diff --git a/frontend/app/web-gold/src/api/auth.js b/frontend/app/web-gold/src/api/auth.js index 5878c27186..3bc2ca99cf 100644 --- a/frontend/app/web-gold/src/api/auth.js +++ b/frontend/app/web-gold/src/api/auth.js @@ -12,26 +12,29 @@ const SERVER_BASE = API_BASE.APP_MEMBER * @param {Object} info - 包含 accessToken 和 refreshToken 的对象 */ function saveTokens(info) { - if (info?.accessToken || info?.refreshToken) { - tokenManager.setTokens({ - accessToken: info.accessToken || '', - refreshToken: info.refreshToken || '', - expiresTime: info.expiresTime || 0, // 直接传递,由 token-manager 处理格式转换 - tokenType: info.tokenType || 'Bearer' - }) + if (!info?.accessToken && !info?.refreshToken) { + return } -} + + tokenManager.setTokens({ + accessToken: info.accessToken || '', + refreshToken: info.refreshToken || '', + expiresTime: info.expiresTime || 0, + tokenType: info.tokenType || 'Bearer' + }) +} /** - * 响应拦截(可选):统一错误处理 + * 清除用户信息缓存 */ -// api.interceptors.response.use( -// (resp) => resp, -// (err) => { -// // 统一错误日志/提示 -// return Promise.reject(err); -// } -// ); +async function clearUserCache() { + try { + const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo') + clearUserInfoCache() + } catch (e) { + // 清除缓存失败不影响登录流程 + } +} /** * 短信场景枚举(请与后端配置保持一致) @@ -50,8 +53,8 @@ export const SMS_SCENE = { * 短信模板编码常量 */ export const SMS_TEMPLATE_CODE = { - USER_REGISTER: 'muye-user-code', // 用户注册模板编码 -}; + USER_REGISTER: 'muye-user-code', +} /** * 账号密码登录 @@ -62,19 +65,11 @@ export const SMS_TEMPLATE_CODE = { * @returns {Promise} data.data: { accessToken, refreshToken, expiresTime, userInfo } */ export async function loginByPassword(mobile, password) { - const { data } = await api.post(`${SERVER_BASE}/auth/login`, { mobile, password }); - const info = data || {}; - saveTokens(info); - - // 清除用户信息缓存,确保登录后获取最新信息 - try { - const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo') - clearUserInfoCache() - } catch (e) { - // 清除缓存失败不影响登录流程 - } - - return info; + const { data } = await api.post(`${SERVER_BASE}/auth/login`, { mobile, password }) + const info = data || {} + saveTokens(info) + await clearUserCache() + return info } /** @@ -87,11 +82,12 @@ export async function loginByPassword(mobile, password) { * @returns {Promise} 后端通用响应结构 */ export async function sendSmsCode(mobile, scene, templateCode) { - const body = { scene }; - if (mobile) body.mobile = mobile; // 修改密码场景后端会根据当前登录用户自动填充手机号 - if (templateCode) body.templateCode = templateCode; // 添加模板编码参数 - const { data } = await api.post(`${SERVER_BASE}/auth/send-sms-code`, body); - return data; + const body = { scene } + if (mobile) body.mobile = mobile + if (templateCode) body.templateCode = templateCode + + const { data } = await api.post(`${SERVER_BASE}/auth/send-sms-code`, body) + return data } /** @@ -104,8 +100,8 @@ export async function sendSmsCode(mobile, scene, templateCode) { * @returns {Promise} 后端通用响应结构 */ export async function validateSmsCode(mobile, code, scene) { - const { data } = await api.post(`${SERVER_BASE}/auth/validate-sms-code`, { mobile, code, scene }); - return data; + const { data } = await api.post(`${SERVER_BASE}/auth/validate-sms-code`, { mobile, code, scene }) + return data } /** @@ -117,19 +113,11 @@ export async function validateSmsCode(mobile, code, scene) { * @returns {Promise} data.data: { accessToken, refreshToken, expiresTime, userInfo } */ export async function loginBySms(mobile, code) { - const { data } = await api.post(`${SERVER_BASE}/auth/sms-login`, { mobile, code }); - const info = data || {}; - saveTokens(info); - - // 清除用户信息缓存,确保登录后获取最新信息 - try { - const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo') - clearUserInfoCache() - } catch (e) { - // 清除缓存失败不影响登录流程 - } - - return info; + const { data } = await api.post(`${SERVER_BASE}/auth/sms-login`, { mobile, code }) + const info = data || {} + saveTokens(info) + await clearUserCache() + return info } /** @@ -139,26 +127,36 @@ export async function loginBySms(mobile, code) { * @returns {Promise} data.data: { accessToken, refreshToken, expiresTime, userInfo } */ export async function refreshToken() { - const rt = tokenManager.getRefreshToken(); - if (!rt) throw new Error('缺少 refresh_token'); - // refreshToken 作为 URL 查询参数传递 - const { data } = await api.post(`${SERVER_BASE}/auth/refresh-token`, null, { params: { refreshToken: rt } }); - const info = data || {}; - saveTokens(info); + const rt = tokenManager.getRefreshToken() + if (!rt) throw new Error('缺少 refresh_token') - // 清除用户信息缓存,因为 token 刷新后用户信息可能已更新 - try { - const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo') - clearUserInfoCache() - } catch (e) { - // 清除缓存失败不影响登录流程 + const { data } = await api.post(`${SERVER_BASE}/auth/refresh-token`, null, { params: { refreshToken: rt } }) + + // 检查业务状态码 + if (data?.code === 401) { + // 401: refreshToken 无效或过期 + tokenManager.clearTokens() + await clearUserCache() + router.push('/login') + + const authError = new Error('登录已过期,请重新登录') + authError.code = 401 + authError.needRelogin = true + throw authError } - return info; + if (data?.code !== 0 && data?.code !== 200) { + throw new Error(data?.message || data?.msg || '刷新token失败') + } + + const info = data || {} + saveTokens(info) + await clearUserCache() + return info } /** - * 登录态下:发送“修改密码”验证码 + * 登录态下:发送"修改密码"验证码 * - 场景:SMS_SCENE.MEMBER_UPDATE_PASSWORD * - 特性:后端会以当前登录用户的手机号为准,无需传 mobile * POST /member/auth/send-sms-code @@ -168,8 +166,8 @@ export async function refreshToken() { export async function sendUpdatePasswordCode() { const { data } = await api.post(`${SERVER_BASE}/auth/send-sms-code`, { scene: SMS_SCENE.MEMBER_UPDATE_PASSWORD, - }); - return data; + }) + return data } /** @@ -184,12 +182,12 @@ export async function updatePasswordBySmsCode(newPassword, smsCode) { const { data } = await api.put(`${SERVER_BASE}/user/update-password`, { password: newPassword, code: smsCode, - }); - return data; + }) + return data } /** - * 未登录:发送“忘记密码”验证码 + * 未登录:发送"忘记密码"验证码 * - 场景:SMS_SCENE.MEMBER_RESET_PASSWORD * POST /member/auth/send-sms-code * @@ -200,8 +198,8 @@ export async function sendResetPasswordCode(mobile) { const { data } = await api.post(`${SERVER_BASE}/auth/send-sms-code`, { mobile, scene: SMS_SCENE.MEMBER_RESET_PASSWORD, - }); - return data; + }) + return data } /** @@ -218,8 +216,8 @@ export async function resetPasswordBySms(mobile, newPassword, smsCode) { mobile, password: newPassword, code: smsCode, - }); - return data; + }) + return data } /** @@ -234,11 +232,11 @@ export async function getUserInfo() { } /** - * “手机+验证码+密码注册”组合流程(基于短信登录即注册 + 设置密码) + * "手机+验证码+密码注册"组合流程(基于短信登录即注册 + 设置密码) * 说明: * - 1) 发送登录场景验证码(可选:若已拿到 code 可跳过) * - 2) 短信登录:首次会自动注册并返回 token - * - 3) 登录态下发送“修改密码”验证码 + * - 3) 登录态下发送"修改密码"验证码 * - 4) 用短信验证码设置密码 * * @param {string} mobile - 手机号(必填) @@ -248,17 +246,11 @@ export async function getUserInfo() { * @returns {Promise} */ export async function registerWithMobileCodePassword(mobile, loginCode, newPassword, updatePwdCode) { - // 1) 可选:发送登录场景验证码(若已拿到 loginCode 可跳过) - // await sendSmsCode(mobile, SMS_SCENE.MEMBER_LOGIN); - // 2) 短信登录(首次即注册) - await loginBySms(mobile, loginCode); - - // 3) 登录态下发送“修改密码”验证码(短信会发到当前账号绑定的手机号) - // await sendUpdatePasswordCode(); + await loginBySms(mobile, loginCode) // 4) 用短信验证码设置密码 - await updatePasswordBySmsCode(newPassword, updatePwdCode); + await updatePasswordBySmsCode(newPassword, updatePwdCode) } /** diff --git a/frontend/app/web-gold/src/api/http.js b/frontend/app/web-gold/src/api/http.js index 456ef9dcd9..c76b38fa03 100644 --- a/frontend/app/web-gold/src/api/http.js +++ b/frontend/app/web-gold/src/api/http.js @@ -22,29 +22,16 @@ export function createHttpClient(options = {}) { const httpClient = createClientAxios({ baseURL: '/', timeout: 180000, - refreshTokenFn: refreshToken, // 传递刷新函数给拦截器 + refreshTokenFn: refreshToken, on401: async (error) => { - // 401:优先使用上层自定义处理 if (on401) { await on401(error) return } - - // 默认处理:尝试刷新token - try { - await refreshToken() - error._handled = true - error._tokenRefreshed = true - } catch (refreshError) { - router.push('/login') - } + router.push('/login') }, on403: (error) => { - if (on403) { - on403(error) - } else { - router.push('/login') - } + on403 ? on403(error) : router.push('/login') }, }) @@ -74,38 +61,16 @@ export default http * * const myHttp = createHttpClient() * myHttp.interceptors.request.use((config) => { - * // 添加自定义请求头 * config.headers['X-Custom-Header'] = 'value' * return config * }) - * await myHttp.post('/api/data') * * 3. 自定义 401 处理 - * import { createHttpClient } from '@/api/http' - * * const myHttp = createHttpClient({ * on401: (error) => { - * // 自定义 401 处理逻辑 * console.log('自定义 401 处理') * } * }) - * - * 4. 高级用法:自动刷新 token 并重试请求 - * import { createHttpClient } from '@/api/http' - * - * const myHttp = createHttpClient() - * - * try { - * const response = await myHttp.post('/api/data') - * console.log(response.data) - * } catch (error) { - * // 如果是 401 且刷新失败,会自动调用 onAuthFailed 回调 - * console.error('请求失败:', error.message) - * } - * - * 注意:当 401 发生时,系统会自动尝试刷新 token。 - * 如果刷新成功,会使用相同的 http 实例重新发起请求, - * 确保自定义拦截器被正确应用。 */