feat: 优化账户相关 API,移除显式传递 token,支持自动注入;添加访问令牌刷新机制

This commit is contained in:
SunWuyuan 2025-11-02 11:15:23 +08:00
parent 971f8c121e
commit 36d0da03fb
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
9 changed files with 250 additions and 80 deletions

View File

@ -56,6 +56,10 @@ watch(isOpen, (newVal) => {
if (newVal && activeTab.value === 'register' && !newUuid.value) {
generateRandomUuid()
}
//
if (newVal && activeTab.value === 'register' && accountStore.isAuthenticated) {
bindToAccount.value = true
}
})
//
@ -69,6 +73,10 @@ watch(activeTab, (newVal) => {
if (newVal === 'register' && !newUuid.value) {
generateRandomUuid()
}
//
if (newVal === 'register') {
bindToAccount.value = accountStore.isAuthenticated
}
})
//
@ -113,7 +121,7 @@ const loadAccountDevices = async () => {
loadingDevices.value = true
try {
const response = await apiClient.getAccountDevices(accountStore.token)
const response = await apiClient.getAccountDevices()
accountDevices.value = response.data || []
if (accountDevices.value.length === 0) {
@ -179,14 +187,13 @@ const registerDevice = async () => {
// 2.
await apiClient.registerDevice(
newUuid.value.trim(),
deviceName.value.trim(),
accountStore.isAuthenticated ? accountStore.token : null
deviceName.value.trim()
)
// 3.
if (bindToAccount.value && accountStore.isAuthenticated) {
try {
await apiClient.bindDeviceToAccount(accountStore.token, newUuid.value.trim())
await apiClient.bindDeviceToAccount(newUuid.value.trim())
} catch (error) {
console.warn('设备绑定失败:', error.message)
toast.warning('设备注册成功,但绑定到账户失败')

View File

@ -33,15 +33,6 @@ const features = [
iconBg: 'bg-purple-500/10',
iconColor: 'text-purple-600 dark:text-purple-400',
},
{
title: 'KV 管理器',
description: '浏览和管理键值存储数据,支持批量操作',
icon: Database,
path: '/kv-manager',
color: 'from-green-500 to-emerald-500',
iconBg: 'bg-green-500/10',
iconColor: 'text-green-600 dark:text-green-400',
},
{
title: '设备管理',
description: '管理您账户下的所有设备,修改名称和密码',

View File

@ -14,6 +14,11 @@ export function useOAuthCallback() {
const handleOAuthCallback = async () => {
const { token, provider, color, success, error } = route.query
// 新版参数access_token / refresh_token / expires_in / providerName / providerColor
const access_token = route.query.access_token || token
const refresh_token = route.query.refresh_token || null
const providerName = route.query.providerName || provider
const providerColor = route.query.providerColor || color
// 检查是否是OAuth回调
if (!success && !error) {
@ -21,15 +26,15 @@ export function useOAuthCallback() {
}
// 处理成功回调
if (success === 'true' && token) {
if (success === 'true' && access_token) {
try {
// 保存token到localStorage
localStorage.setItem('auth_token', token)
localStorage.setItem('auth_provider', provider)
if (color) localStorage.setItem('auth_provider_color', color)
// 保存到 Store同时写入 localStorage
accountStore.setTokens(access_token, refresh_token)
if (providerName) localStorage.setItem('auth_provider', providerName)
if (providerColor) localStorage.setItem('auth_provider_color', providerColor)
// 登录到store
await accountStore.login(token)
// 登录到store(加载资料、设备)
await accountStore.login(access_token)
// 显示成功提示
toast.success('登录成功', {
@ -37,22 +42,21 @@ export function useOAuthCallback() {
})
// 清除URL参数
router.replace({ query: {} })
router.replace({ query: {} })
// 触发storage事件通知其他窗口
window.dispatchEvent(new StorageEvent('storage', {
key: 'auth_token',
newValue: token,
url: window.location.href
}))
window.dispatchEvent(new StorageEvent('storage', { key: 'auth_token', newValue: access_token, url: window.location.href }))
if (refresh_token) {
window.dispatchEvent(new StorageEvent('storage', { key: 'auth_refresh_token', newValue: refresh_token, url: window.location.href }))
}
// 如果是在新窗口中打开的OAuth回调自动关闭窗口
if (window.opener) {
// 通知父窗口登录成功
window.opener.postMessage({
type: 'oauth_success',
token,
provider,
token: access_token,
provider: providerName,
}, window.location.origin)
// 延迟关闭窗口,确保消息已发送
@ -111,6 +115,9 @@ export function useOAuthCallback() {
// 其他标签页已登录,刷新当前页面的状态
accountStore.login(e.newValue)
}
if (e.key === 'auth_refresh_token' && e.newValue) {
accountStore.setTokens(accountStore.token, e.newValue)
}
}
onMounted(() => {

View File

@ -310,35 +310,29 @@ class ApiClient {
}
}
// 账户相关 API
// 账户相关 APIAuthorization 由 axios 拦截器统一注入)
async getOAuthProviders() {
return this.fetch('/accounts/oauth/providers')
}
async getAccountProfile(token) {
return this.fetch('/accounts/profile', {
headers: { 'Authorization': `Bearer ${token}` }
})
async getAccountProfile() {
return this.fetch('/accounts/profile')
}
async getAccountDevices(token) {
return this.fetch('/accounts/devices', {
headers: { 'Authorization': `Bearer ${token}` }
})
async getAccountDevices() {
return this.fetch('/accounts/devices')
}
async bindDevice(token, deviceUuid) {
async bindDevice(deviceUuid) {
return this.fetch('/accounts/devices/bind', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ uuid: deviceUuid }),
})
}
async unbindDevice(token, deviceUuid) {
async unbindDevice(deviceUuid) {
return this.fetch('/accounts/devices/unbind', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ uuid: deviceUuid }),
})
}
@ -347,28 +341,44 @@ class ApiClient {
return this.fetch(`/accounts/device/${deviceUuid}/account`)
}
// ===== 账户 Token 刷新与信息 =====
async refreshAccessToken(refreshToken) {
return this.fetch('/accounts/refresh', {
method: 'POST',
// 刷新接口不应由请求拦截器附加旧的 Authorization
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
})
}
async getTokenInfo(accessToken) {
return this.fetch('/accounts/token-info', {
headers: { 'Authorization': `Bearer ${accessToken}` }
})
}
// 绑定设备到当前账户
async bindDeviceToAccount(token, deviceUuid) {
return this.authenticatedFetch('/accounts/devices/bind', {
async bindDeviceToAccount(deviceUuid) {
return this.fetch('/accounts/devices/bind', {
method: 'POST',
body: JSON.stringify({ uuid: deviceUuid }),
}, token)
})
}
// 解绑设备
async unbindDeviceFromAccount(token, deviceUuid) {
return this.authenticatedFetch('/accounts/devices/unbind', {
async unbindDeviceFromAccount(deviceUuid) {
return this.fetch('/accounts/devices/unbind', {
method: 'POST',
body: JSON.stringify({ uuid: deviceUuid }),
}, token)
})
}
// 批量解绑设备
async batchUnbindDevices(token, deviceUuids) {
return this.authenticatedFetch('/accounts/devices/unbind', {
async batchUnbindDevices(deviceUuids) {
return this.fetch('/accounts/devices/unbind', {
method: 'POST',
body: JSON.stringify({ uuids: deviceUuids }),
}, token)
})
}
// 设备名称管理 API

View File

@ -14,9 +14,42 @@ const axiosInstance = axios.create({
},
})
// ===== 认证相关 Hooks由账户 Store 注入)=====
let authHandlers = {
// 可选:返回访问令牌
getAccessToken: () => null,
// 可选:返回刷新令牌
getRefreshToken: () => null,
// 可选:仅更新访问令牌
setAccessToken: (_t) => {},
// 可选:执行刷新动作,返回新的访问令牌
refreshAccessToken: null,
// 可选:当刷新失败时回调(例如触发登出)
onAuthFailure: () => {},
}
// 对外方法:由外部(如 Pinia store注入实际的处理函数
export function setAuthHandlers(handlers) {
authHandlers = { ...authHandlers, ...(handlers || {}) }
}
// 请求拦截器
axiosInstance.interceptors.request.use(
(config) => {
try {
// 为非刷新接口自动附加 Authorization 头
const isRefreshRequest = typeof config.url === 'string' && /\/accounts\/refresh(\b|\/|\?)/.test(config.url)
const skipAuth = config.__skipAuth === true || isRefreshRequest
if (!skipAuth && authHandlers?.getAccessToken) {
const token = authHandlers.getAccessToken()
if (token) {
config.headers = config.headers || {}
config.headers['Authorization'] = `Bearer ${token}`
}
}
} catch {
// ignore
}
return config
},
(error) => {
@ -96,17 +129,62 @@ async function ensureDeviceRegistered(uuid, authHeader) {
}
}
// 响应拦截器(含自动注册并重试)
// 刷新中的 Promise用于合并并发 401 刷新)
let refreshingPromise = null
// 响应拦截器含自动换发、自动注册并重试、401 刷新)
axiosInstance.interceptors.response.use(
(response) => {
// 如果服务端主动通过响应头下发新访问令牌,更新本地
try {
const headers = response?.headers || {}
const newToken = getHeaderIgnoreCase(headers, 'X-New-Access-Token')
if (newToken && authHandlers?.setAccessToken) {
authHandlers.setAccessToken(newToken)
}
} catch {}
return response.data
},
async (error) => {
const config = error.config || {}
const resp = error.response
const status = resp?.status
const skip = config.skipDeviceRegistrationRetry || config.__isRegistrationRequest
const backendMessage = error.response?.data?.message
const backendMessage = resp?.data?.message
const message = backendMessage || error.message || 'Unknown error'
// 优先处理 401尝试使用刷新令牌换发
if (status === 401 && !config.__retriedAfterRefresh) {
try {
// 若没有刷新能力或没有刷新令牌,则直接走失败逻辑
if (!authHandlers?.refreshAccessToken || !authHandlers?.getRefreshToken || !authHandlers.getRefreshToken()) {
throw new Error('NO_REFRESH_TOKEN')
}
// 合并并发刷新
if (!refreshingPromise) {
refreshingPromise = authHandlers.refreshAccessToken()
.catch((e) => {
// 刷新失败,触发失败处理
try { authHandlers?.onAuthFailure && authHandlers.onAuthFailure(e) } catch {}
throw e
})
.finally(() => {
refreshingPromise = null
})
}
await refreshingPromise
// 刷新成功后重试一次原请求
config.__retriedAfterRefresh = true
// 由请求拦截器负责附加新 Authorization无需手动改 headers
return await axiosInstance.request(config)
} catch (refreshErr) {
// 刷新失败,返回原始错误信息
return Promise.reject(new Error(message))
}
}
// 仅在后端提示设备不存在时尝试注册并重试,且保证只重试一次
if (!skip && !config.__retriedAfterRegistration && typeof backendMessage === 'string' && backendMessage.startsWith('设备不存在')) {
// 从 headers / url / body 提取 uuid
@ -139,6 +217,7 @@ axiosInstance.interceptors.response.use(
}
}
// 其他错误:附带信息抛出
return Promise.reject(new Error(message))
}
)

View File

@ -108,9 +108,9 @@ const checkDeviceAndLoad = async () => {
try {
deviceInfo.value = await apiClient.getDeviceInfo(uuid)
console.log(deviceInfo.value)
console.log(accountStore)
console.log(accountStore.profile)
//
if (!deviceInfo.value.account.id || deviceInfo.value.account.id !== accountStore.profile.id) {
if (!deviceInfo.value.account || !deviceInfo.value.account.id || deviceInfo.value.account.id !== accountStore.profile.id) {
toast.error('该设备未绑定到您的账户', {
description: '请先在主页绑定设备到您的账户'
})

View File

@ -48,7 +48,7 @@ const loadDevices = async () => {
isLoading.value = true
try {
const response = await apiClient.getAccountDevices(accountStore.token)
const response = await apiClient.getAccountDevices()
devices.value = response.data || []
} catch (error) {
toast.error('加载设备列表失败:' + error.message)
@ -68,7 +68,7 @@ const unbindDevice = async () => {
if (!currentDevice.value) return
try {
await apiClient.unbindDeviceFromAccount(accountStore.token, currentDevice.value.uuid)
await apiClient.unbindDeviceFromAccount(currentDevice.value.uuid)
toast.success('设备已解绑')
showDeleteDialog.value = false
currentDevice.value = null

View File

@ -161,9 +161,7 @@ const authorizeApp = async () => {
options.password = authPassword.value
}
if (accountStore.isAuthenticated) {
options.token = accountStore.token
}
// tokenaxios Authorization
//
await apiClient.authorizeApp(
@ -327,7 +325,7 @@ const bindCurrentDevice = async () => {
}
try {
await apiClient.bindDeviceToAccount(accountStore.token, deviceUuid.value)
await apiClient.bindDeviceToAccount(deviceUuid.value)
await loadDeviceInfo()
toast.success('设备已绑定到您的账户')
} catch (error) {
@ -336,10 +334,9 @@ const bindCurrentDevice = async () => {
try {
await apiClient.registerDevice(
deviceUuid.value,
deviceInfo.value?.deviceName || null,
accountStore.token
deviceInfo.value?.deviceName || null
)
await apiClient.bindDeviceToAccount(accountStore.token, deviceUuid.value)
await apiClient.bindDeviceToAccount(deviceUuid.value)
await loadDeviceInfo()
toast.success('设备已注册并绑定到您的账户')
} catch (retryError) {
@ -359,7 +356,7 @@ const unbindCurrentDevice = async () => {
}
try {
await apiClient.unbindDeviceFromAccount(accountStore.token, deviceUuid.value)
await apiClient.unbindDeviceFromAccount(deviceUuid.value)
await loadDeviceInfo()
toast.success('设备已解绑')
} catch (error) {
@ -421,7 +418,7 @@ onMounted(async () => {
<h1 class="text-2xl font-bold bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text">
Classworks KV
</h1>
<p class="text-sm text-muted-foreground">云原生键值数据库</p>
<p class="text-sm text-muted-foreground">文档形键值数据库</p>
</div>
</div>
<div class="flex items-center gap-2">

View File

@ -1,15 +1,20 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiClient } from '@/lib/api'
import { setAuthHandlers } from '@/lib/axios'
export const useAccountStore = defineStore('account', () => {
// 状态
const token = ref(localStorage.getItem('auth_token') || null)
// 访问令牌(兼容旧 key: auth_token
const token = ref(localStorage.getItem('auth_token') || localStorage.getItem('auth_access_token') || null)
const refreshToken = ref(localStorage.getItem('auth_refresh_token') || null)
const accessExpiresAt = ref(Number(localStorage.getItem('auth_access_exp') || 0) || 0) // ms 时间戳
const profile = ref(null)
const devices = ref([])
const loading = ref(false)
const providerName = ref(localStorage.getItem('auth_provider') || '')
const providerColor = ref(localStorage.getItem('auth_provider_color') || '')
let proactiveTimer = null
// 计算属性
const isAuthenticated = computed(() => !!token.value)
@ -19,17 +24,70 @@ export const useAccountStore = defineStore('account', () => {
const userProviderDisplay = computed(() => profile.value?.providerInfo?.displayName || profile.value?.providerInfo?.name || providerName.value || profile.value?.provider || '')
const userProviderColor = computed(() => profile.value?.providerInfo?.color || providerColor.value || '')
// 工具:解析 JWT 过期时间(秒时间戳),返回 ms
function decodeJwtExpMs(jwt) {
try {
const [, payload] = jwt.split('.')
if (!payload) return 0
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')))
if (!json?.exp) return 0
return Number(json.exp) * 1000
} catch {
return 0
}
}
function clearProactiveTimer() {
if (proactiveTimer) {
clearTimeout(proactiveTimer)
proactiveTimer = null
}
}
function scheduleProactiveRefresh() {
clearProactiveTimer()
if (!token.value) return
const expMs = accessExpiresAt.value || decodeJwtExpMs(token.value)
if (!expMs) return
const now = Date.now()
// 提前 2 分钟刷新,最小 5 秒
const lead = 2 * 60 * 1000
let delay = expMs - now - lead
if (delay < 5000) delay = 5000
proactiveTimer = setTimeout(() => {
refreshNow().catch(() => {})
}, delay)
}
// 方法
const setToken = (newToken) => {
token.value = newToken
if (newToken) {
localStorage.setItem('auth_token', newToken)
localStorage.setItem('auth_access_token', newToken)
accessExpiresAt.value = decodeJwtExpMs(newToken) || 0
if (accessExpiresAt.value) localStorage.setItem('auth_access_exp', String(accessExpiresAt.value))
scheduleProactiveRefresh()
} else {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_access_token')
localStorage.removeItem('auth_access_exp')
localStorage.removeItem('auth_refresh_token')
localStorage.removeItem('auth_provider')
localStorage.removeItem('auth_provider_color')
providerName.value = ''
providerColor.value = ''
refreshToken.value = null
accessExpiresAt.value = 0
clearProactiveTimer()
}
}
const setTokens = (access, refresh) => {
if (access) setToken(access)
if (refresh) {
refreshToken.value = refresh
localStorage.setItem('auth_refresh_token', refresh)
}
}
@ -38,7 +96,7 @@ export const useAccountStore = defineStore('account', () => {
loading.value = true
try {
const response = await apiClient.getAccountProfile(token.value)
const response = await apiClient.getAccountProfile()
profile.value = response.data
// 若后端返回 providerInfo则回填前端展示字段
const p = profile.value?.providerInfo
@ -50,10 +108,6 @@ export const useAccountStore = defineStore('account', () => {
}
} catch (error) {
console.error('Failed to load profile:', error)
// Token可能无效清除
if (error.message.includes('401')) {
logout()
}
} finally {
loading.value = false
}
@ -63,7 +117,7 @@ export const useAccountStore = defineStore('account', () => {
if (!token.value) return
try {
const response = await apiClient.getAccountDevices(token.value)
const response = await apiClient.getAccountDevices()
devices.value = response.data || []
return devices.value
} catch (error) {
@ -75,7 +129,7 @@ export const useAccountStore = defineStore('account', () => {
const bindDevice = async (deviceUuid) => {
if (!token.value) throw new Error('未登录')
const response = await apiClient.bindDevice(token.value, deviceUuid)
const response = await apiClient.bindDevice(deviceUuid)
// 重新加载设备列表
await loadDevices()
return response
@ -84,7 +138,7 @@ export const useAccountStore = defineStore('account', () => {
const unbindDevice = async (deviceUuid) => {
if (!token.value) throw new Error('未登录')
const response = await apiClient.unbindDevice(token.value, deviceUuid)
const response = await apiClient.unbindDevice(deviceUuid)
// 重新加载设备列表
await loadDevices()
return response
@ -100,15 +154,37 @@ export const useAccountStore = defineStore('account', () => {
token.value = null
profile.value = null
devices.value = []
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_provider')
localStorage.removeItem('auth_provider_color')
providerName.value = ''
providerColor.value = ''
setToken(null)
}
// 初始化时加载用户信息
// 刷新访问令牌(按需刷新)
const refreshNow = async () => {
if (!refreshToken.value) throw new Error('No refresh token')
const res = await apiClient.refreshAccessToken(refreshToken.value)
// 期望结构:{ success, data: { access_token, expires_in, account? } }
const newAccess = res?.data?.access_token || res?.access_token || res?.token || null
if (!newAccess) throw new Error(res?.message || '刷新失败')
setToken(newAccess)
return newAccess
}
// 初始化注入 axios 认证处理
setAuthHandlers({
getAccessToken: () => token.value,
getRefreshToken: () => refreshToken.value,
setAccessToken: (t) => setToken(t),
refreshAccessToken: () => refreshNow(),
onAuthFailure: () => logout(),
})
// 初始化:迁移旧存储并设置定时刷新
if (token.value) {
// 若未存储过期时间尝试从JWT解析
if (!accessExpiresAt.value) {
accessExpiresAt.value = decodeJwtExpMs(token.value) || 0
if (accessExpiresAt.value) localStorage.setItem('auth_access_exp', String(accessExpiresAt.value))
}
scheduleProactiveRefresh()
loadProfile()
loadDevices()
}
@ -116,6 +192,7 @@ export const useAccountStore = defineStore('account', () => {
return {
// 状态
token,
refreshToken,
profile,
devices,
loading,
@ -130,11 +207,13 @@ export const useAccountStore = defineStore('account', () => {
userProviderColor,
// 方法
setToken,
setTokens,
loadProfile,
loadDevices,
bindDevice,
unbindDevice,
login,
logout,
refreshNow,
}
})