feat: 重构HTTP客户端架构和认证系统

核心改进:
- HTTP客户端:工厂函数模式,支持自定义拦截器和401/403处理
- 认证服务:函数式实现,消除this绑定问题,支持业务码+HTTP状态码双通道
- Token管理:简化为直接实例导出,移除bind()和箭头函数包装
- 路由守卫:优化逻辑,移除冗余代码,更简洁易维护

技术亮点:
- 统一401/403错误处理(业务code和HTTP status双检查)
- 自动刷新token并重试请求,保留自定义拦截器
- 分层清晰:clientAxios (Mono) -> http (应用) -> AuthService
- 支持扩展:业务代码可创建自定义HTTP实例并添加拦截器

文件变更:
- 新增 AuthService.js (函数式) 和 Login.vue
- 重构 http.js、token-manager.js、router/index.js
- 删除 TokenInput.vue、utils/auth.js 等冗余文件
- 更新所有API调用点使用直接实例导入

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-25 00:58:51 +08:00
parent cea43dd635
commit fb6d18b4f5
24 changed files with 1148 additions and 1198 deletions

View File

@@ -1,215 +1,232 @@
import { useCache, CACHE_KEY, deleteTokenCache } from '../hooks/web/useCache'
/**
* Token 统一管理模块Monorepo 全局工具)
*
* 这是项目中唯一的 token 管理入口,所有 token 操作都应该通过此模块进行。
*
* Token 存储优先级(读取顺序):
* 1. 手动输入的 dev token (sessionStorage)
* 2. 正式登录的 token (wsCache/localStorage)
* 3. 环境变量 VITE_DEV_TOKEN
*
* Token 存储位置:
* - ACCESS_TOKEN/access_token: 访问令牌wsCache/localStorage
* - REFRESH_TOKEN/refresh_token: 刷新令牌wsCache/localStorage
* - DEV_MANUAL_TOKEN: 开发手动输入的tokensessionStorage
* Token 统一管理
*
* 功能特性:
* - ✅ 安全存储访问令牌、刷新令牌、过期时间等信息
* - ✅ 自动检查令牌是否过期(支持提前刷新缓冲时间)
* * ✅ 提供订阅者模式,监听令牌变化事件
* ✅ 兼容旧版本 API提供 getToken()、removeToken() 等方法
*
* 使用示例:
* ```javascript
* import { getAccessToken, setTokens, isLoggedIn } from '@gold/utils/token-manager'
*
* // 检查登录状态
* if (isLoggedIn()) {
* const token = getAccessToken()
* }
*
* // 设置令牌
* setTokens({
* accessToken: 'eyJ...',
* refreshToken: 'eyJ...',
* expiresIn: 7200,
* tokenType: 'Bearer'
* })
* ```
*/
// ==================== 常量定义 ====================
const DEV_MANUAL_TOKEN_KEY = 'DEV_MANUAL_TOKEN'
// Token 键名变体(支持大小写不同)
// localStorage 中存储的键名常量
const TOKEN_KEYS = {
ACCESS: [CACHE_KEY.ACCESS_TOKEN, 'access_token'],
REFRESH: [CACHE_KEY.REFRESH_TOKEN, 'refresh_token']
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
EXPIRES_TIME: 'expires_time',
TOKEN_TYPE: 'token_type',
}
// ==================== 缓存实例管理 ====================
let wsCache = null
class TokenManager {
constructor() {
this.subscribers = [] // 订阅token变化的回调
}
/**
* 获取 wsCache 实例(延迟初始化,避免模块加载时出错)
* @returns {Object} wsCache 实例
*/
function getCache() {
if (!wsCache) {
try {
wsCache = useCache().wsCache
} catch (e) {
console.warn('初始化 wsCache 失败:', e)
// 返回一个安全的空对象,避免后续调用出错
wsCache = {
get: () => null,
set: () => {},
delete: () => {}
}
/**
* 获取访问令牌
* @returns {string|null} 访问令牌,如果不存在则返回 null
*/
getAccessToken() {
return localStorage.getItem(TOKEN_KEYS.ACCESS_TOKEN)
}
/**
* 获取刷新令牌
* @returns {string|null} 刷新令牌,如果不存在则返回 null
*/
getRefreshToken() {
return localStorage.getItem(TOKEN_KEYS.REFRESH_TOKEN)
}
/**
* 获取过期时间(毫秒时间戳)
* @returns {number} 过期时间戳,如果未设置则返回 0
*/
getExpiresTime() {
const expiresTimeStr = localStorage.getItem(TOKEN_KEYS.EXPIRES_TIME)
return expiresTimeStr ? parseInt(expiresTimeStr, 10) : 0
}
/**
* 获取令牌类型
* @returns {string} 令牌类型,默认为 'Bearer'
*/
getTokenType() {
return localStorage.getItem(TOKEN_KEYS.TOKEN_TYPE) || 'Bearer'
}
/**
* 检查访问令牌是否已过期
* @param {number} bufferTime - 提前刷新缓冲时间(毫秒),默认 5 分钟
* @returns {boolean} 如果已过期或即将过期(超过缓冲时间)则返回 true
*/
isExpired(bufferTime = 5 * 60 * 1000) {
const expiresTime = this.getExpiresTime()
const now = Date.now()
// 没有过期时间或当前时间已超过(过期时间 - 缓冲时间)
return !expiresTime || now >= (expiresTime - bufferTime)
}
/**
* 检查用户是否已登录(有有效的访问令牌)
* @returns {boolean} 如果有有效令牌则返回 true
*/
isLoggedIn() {
const token = this.getAccessToken()
return Boolean(token) && !this.isExpired()
}
/**
* 存储访问令牌和相关信息到本地存储
* @param {Object} tokenInfo - 令牌信息对象
* @param {string} tokenInfo.accessToken - 访问令牌(必填)
* @param {string} tokenInfo.refreshToken - 刷新令牌(可选)
* @param {number} tokenInfo.expiresIn - 令牌有效期(秒,可选)
* @param {string} tokenInfo.tokenType - 令牌类型,默认为 'Bearer'
*/
setTokens(tokenInfo) {
const {
accessToken,
refreshToken,
expiresIn,
tokenType = 'Bearer'
} = tokenInfo
// 校验:必须提供访问令牌
if (!accessToken) {
console.error('[TokenManager] 设置令牌失败:缺少 accessToken')
return
}
// 将过期时间从秒转换为毫秒时间戳
const expiresTime = expiresIn ? Date.now() + (expiresIn * 1000) : 0
// 存储到 localStorage
localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN, accessToken)
if (refreshToken) {
localStorage.setItem(TOKEN_KEYS.REFRESH_TOKEN, refreshToken)
}
if (expiresTime) {
localStorage.setItem(TOKEN_KEYS.EXPIRES_TIME, String(expiresTime))
}
localStorage.setItem(TOKEN_KEYS.TOKEN_TYPE, tokenType)
// 通知所有订阅者令牌已更新
this.notifySubscribers('token-set', tokenInfo)
}
/**
* 清除所有存储的令牌信息
* 从 localStorage 中删除所有令牌相关数据,并通知订阅者
*/
clearTokens() {
// 删除所有令牌相关的 localStorage 项
Object.values(TOKEN_KEYS).forEach(key => {
localStorage.removeItem(key)
})
// 通知所有订阅者令牌已被清除
this.notifySubscribers('token-cleared', null)
}
/**
* 生成完整的 Authorization 请求头值
* @returns {string} 例如:"Bearer eyJhbGciOi...",如果没有令牌则返回空字符串
*/
getAuthHeader() {
const token = this.getAccessToken()
const tokenType = this.getTokenType()
// 只有当令牌存在时才返回完整的 Authorization 头
return token ? `${tokenType} ${token}` : ''
}
/**
* 订阅令牌变化事件
* @param {Function} callback - 回调函数,接收 (type, tokenInfo) 两个参数
* - type: 事件类型('token-set' | 'token-cleared'
* - tokenInfo: 令牌信息对象或 null
*/
subscribe(callback) {
// 仅接受函数类型的回调
if (typeof callback === 'function') {
this.subscribers.push(callback)
}
}
return wsCache
}
/**
* 获取完整的 Authorization Header 值
* @returns {string} Bearer token 或空字符串
*/
export function getAuthHeader() {
const token = getToken()
return token ? `Bearer ${token}` : ''
}
/**
* 取消令牌变化事件订阅
* @param {Function} callback - 要取消的回调函数
*/
unsubscribe(callback) {
const index = this.subscribers.indexOf(callback)
/**
* 获取 token
* @returns {string} token 字符串
*/
export function getToken() {
// 1. 优先使用手动输入的 dev token
const manualToken = sessionStorage.getItem(DEV_MANUAL_TOKEN_KEY)
if (manualToken) {
return manualToken
}
// 2. 使用正式登录的 token从 wsCache 读取)
try {
const cache = getCache()
// 尝试所有可能的键名变体
for (const key of TOKEN_KEYS.ACCESS) {
const accessToken = cache.get(key)
if (accessToken) {
return accessToken
}
// 如果找到回调函数,则从数组中移除
if (index !== -1) {
this.subscribers.splice(index, 1)
}
} catch (e) {
console.warn('获取 wsCache 失败:', e)
}
// 3. 兜底:环境变量中的 token
const envToken = import.meta?.env?.VITE_DEV_TOKEN
if (envToken) {
return envToken
}
return ''
}
/**
* 设置手动输入的 dev token
* @param {string} token
*/
export function setDevToken(token) {
if (token) {
sessionStorage.setItem(DEV_MANUAL_TOKEN_KEY, token)
} else {
sessionStorage.removeItem(DEV_MANUAL_TOKEN_KEY)
}
}
/**
* 获取手动输入的 dev token用于显示
* @returns {string}
*/
export function getDevToken() {
return sessionStorage.getItem(DEV_MANUAL_TOKEN_KEY) || ''
}
/**
* 设置访问令牌和刷新令牌
* @param {Object} tokens - token 对象
* @param {string} tokens.accessToken - 访问令牌
* @param {string} tokens.refreshToken - 刷新令牌
*/
export function setToken(tokens) {
if (!tokens || (!tokens.accessToken && !tokens.refreshToken)) {
console.warn('setToken: token 参数无效', tokens)
return
}
try {
const cache = getCache()
if (tokens.accessToken) {
cache.set(CACHE_KEY.ACCESS_TOKEN, tokens.accessToken)
}
if (tokens.refreshToken) {
cache.set(CACHE_KEY.REFRESH_TOKEN, tokens.refreshToken)
}
} catch (e) {
console.error('设置 token 失败:', e)
}
}
/**
* 获取刷新令牌
* @returns {string|null} 刷新令牌或 null
*/
export function getRefreshToken() {
try {
const cache = getCache()
// 尝试所有可能的键名变体
for (const key of TOKEN_KEYS.REFRESH) {
const token = cache.get(key)
if (token) {
return token
}
}
} catch (e) {
console.warn('获取 refresh token 失败:', e)
}
return null
}
/**
* 清除所有 token
* 清空所有可能的 token 存储位置,确保完全清除
*/
export function clearAllTokens() {
try {
// 1. 清空 sessionStorage 中的 dev token
sessionStorage.removeItem(DEV_MANUAL_TOKEN_KEY)
} catch (e) {
console.warn('清除 sessionStorage token 失败:', e)
}
try {
// 2. 清空 wsCache (localStorage) 中的所有可能的 token 键名
// 使用统一的 deleteTokenCache 函数,保持与 useCache 的一致性
deleteTokenCache()
} catch (e) {
console.warn('清除 wsCache token 失败:', e)
}
try {
// 3. 清空 localStorage 中可能的 token兜底处理
// 注意wsCache 默认使用 localStorage但为了确保清除也直接操作 localStorage
const localStorageKeys = [
...TOKEN_KEYS.ACCESS,
...TOKEN_KEYS.REFRESH,
DEV_MANUAL_TOKEN_KEY
]
localStorageKeys.forEach(key => {
/**
* 通知所有订阅者令牌已发生变化
* @param {string} type - 事件类型
* @param {Object|null} tokenInfo - 令牌信息
*/
notifySubscribers(type, tokenInfo) {
// 遍历所有订阅者并调用其回调函数
this.subscribers.forEach(callback => {
try {
localStorage.removeItem(key)
} catch (e) {
// 忽略单个键删除失败
callback(type, tokenInfo)
} catch (error) {
// 订阅者回调出错不应影响其他回调的执行
console.error('[TokenManager] 订阅者回调执行失败:', error)
}
})
} catch (e) {
console.warn('清除 localStorage token 失败:', e)
}
/**
* 【兼容方法】获取访问令牌
* @deprecated 请使用 getAccessToken() 替代
* @returns {string|null} 访问令牌
*/
getToken() {
return this.getAccessToken()
}
/**
* 【兼容方法】移除所有令牌
* @deprecated 请使用 clearTokens() 替代
*/
removeToken() {
this.clearTokens()
}
}
/**
* 删除 token别名兼容旧代码
* @deprecated 使用 clearAllTokens() 代替
*/
export function removeToken() {
clearAllTokens()
}
/**
* 获取访问令牌(别名,兼容旧代码)
* @returns {string} token 字符串
*/
export function getAccessToken() {
return getToken()
}
// 创建 TokenManager 单例实例
const tokenManager = new TokenManager()
// 最简单的导出方式:直接导出实例
// 使用方法import tokenManager from '@gold/utils/token-manager'
// tokenManager.getAccessToken()
export default tokenManager