mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 10:27:46 +08:00
refactor(workspace): enforce layered crate boundaries
This commit is contained in:
@@ -106,6 +106,119 @@ describe('auth store logout', () => {
|
||||
expect(store.token).toBeNull()
|
||||
})
|
||||
|
||||
it('deduplicates concurrent current-user requests', async () => {
|
||||
let resolveCurrentUser: ((user: {
|
||||
id: string
|
||||
username: string
|
||||
role: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}) => void) | null = null
|
||||
getTokenMock.mockReturnValue('access-token')
|
||||
getCurrentUserMock.mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolveCurrentUser = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const store = useAuthStore()
|
||||
const firstRequest = store.fetchCurrentUser()
|
||||
const secondRequest = store.fetchCurrentUser()
|
||||
|
||||
expect(getCurrentUserMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const currentUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
role: 'user',
|
||||
is_active: true,
|
||||
created_at: '2026-03-16T00:00:00Z',
|
||||
}
|
||||
resolveCurrentUser?.(currentUser)
|
||||
|
||||
await expect(firstRequest).resolves.toEqual(currentUser)
|
||||
await expect(secondRequest).resolves.toEqual(currentUser)
|
||||
})
|
||||
|
||||
it('does not repeat a failed current-user request on every navigation', async () => {
|
||||
getTokenMock.mockReturnValue('access-token')
|
||||
getCurrentUserMock.mockRejectedValue(new Error('network unavailable'))
|
||||
|
||||
const store = useAuthStore()
|
||||
await store.fetchCurrentUser()
|
||||
await store.fetchCurrentUser()
|
||||
|
||||
expect(store.token).toBe('access-token')
|
||||
expect(store.user).toBeNull()
|
||||
expect(getCurrentUserMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not restore a stale user after logout while the request is in flight', async () => {
|
||||
let resolveCurrentUser: ((user: {
|
||||
id: string
|
||||
username: string
|
||||
role: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}) => void) | null = null
|
||||
getTokenMock.mockReturnValue('access-token')
|
||||
getCurrentUserMock.mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolveCurrentUser = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const store = useAuthStore()
|
||||
const currentUserRequest = store.fetchCurrentUser()
|
||||
await store.logout()
|
||||
resolveCurrentUser?.({
|
||||
id: 'stale-user',
|
||||
username: 'stale',
|
||||
role: 'user',
|
||||
is_active: true,
|
||||
created_at: '2026-03-16T00:00:00Z',
|
||||
})
|
||||
|
||||
await expect(currentUserRequest).resolves.toBeNull()
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.token).toBeNull()
|
||||
})
|
||||
|
||||
it('does not restore a stale token when an in-flight request fails after logout', async () => {
|
||||
let rejectCurrentUser: ((error: Error) => void) | null = null
|
||||
getTokenMock.mockReturnValue('access-token')
|
||||
getCurrentUserMock.mockImplementation(
|
||||
() => new Promise((_resolve, reject) => {
|
||||
rejectCurrentUser = reject
|
||||
})
|
||||
)
|
||||
|
||||
const store = useAuthStore()
|
||||
const currentUserRequest = store.fetchCurrentUser()
|
||||
await store.logout()
|
||||
rejectCurrentUser?.(new Error('stale request failed'))
|
||||
|
||||
await expect(currentUserRequest).resolves.toBeNull()
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.token).toBeNull()
|
||||
})
|
||||
|
||||
it('skips the delayed auth check when the router already loaded the user', async () => {
|
||||
getTokenMock.mockReturnValue('access-token')
|
||||
const store = useAuthStore()
|
||||
store.user = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
role: 'user',
|
||||
is_active: true,
|
||||
created_at: '2026-03-16T00:00:00Z',
|
||||
}
|
||||
|
||||
await store.checkAuth()
|
||||
|
||||
expect(getCurrentUserMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('separates admin access from admin operations for audit administrators', () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
|
||||
+84
-20
@@ -7,6 +7,8 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import { getErrorStatus } from '@/types/api-error'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const CURRENT_USER_FAILURE_BACKOFF_MS = 15_000
|
||||
|
||||
// 初始化时从 localStorage 恢复 token
|
||||
const storedToken = apiClient.getToken()
|
||||
|
||||
@@ -14,6 +16,21 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(storedToken)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let fetchCurrentUserPromise: Promise<User | null> | null = null
|
||||
let fetchCurrentUserToken: string | null = null
|
||||
let lastCurrentUserFailureAt = 0
|
||||
let lastCurrentUserFailureToken: string | null = null
|
||||
let authStateVersion = 0
|
||||
|
||||
function resetCurrentUserFailure() {
|
||||
lastCurrentUserFailureAt = 0
|
||||
lastCurrentUserFailureToken = null
|
||||
}
|
||||
|
||||
function markAuthStateChanged() {
|
||||
authStateVersion += 1
|
||||
resetCurrentUserFailure()
|
||||
}
|
||||
|
||||
const isAuthenticated = computed(() => {
|
||||
// 使用 store 中的 token 状态判断认证状态
|
||||
@@ -29,6 +46,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const currentToken = apiClient.getToken()
|
||||
if (token.value !== currentToken) {
|
||||
token.value = currentToken
|
||||
markAuthStateChanged()
|
||||
}
|
||||
}
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
@@ -43,10 +61,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
try {
|
||||
const response = await authApi.login({ email, password, auth_type: authType })
|
||||
token.value = response.access_token
|
||||
markAuthStateChanged()
|
||||
|
||||
// 获取用户信息
|
||||
const userInfo = await authApi.getCurrentUser()
|
||||
user.value = userInfo
|
||||
resetCurrentUserFailure()
|
||||
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
@@ -73,6 +93,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
async function logout() {
|
||||
user.value = null
|
||||
token.value = null
|
||||
markAuthStateChanged()
|
||||
await authApi.logout()
|
||||
}
|
||||
|
||||
@@ -80,32 +101,75 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
user.value = null
|
||||
token.value = null
|
||||
error.value = null
|
||||
markAuthStateChanged()
|
||||
}
|
||||
|
||||
async function fetchCurrentUser() {
|
||||
try {
|
||||
const userInfo = await authApi.getCurrentUser()
|
||||
user.value = userInfo
|
||||
return userInfo
|
||||
} catch (err: unknown) {
|
||||
log.error('Failed to fetch user info', err)
|
||||
syncToken()
|
||||
if (!token.value) {
|
||||
user.value = null
|
||||
}
|
||||
// 根据用户要求,不管什么错误都不清除状态
|
||||
// 保持登录状态,除非用户手动退出
|
||||
log.info('Keeping session despite error, as per user requirement')
|
||||
return null
|
||||
function fetchCurrentUser(): Promise<User | null> {
|
||||
const requestToken = token.value || apiClient.getToken()
|
||||
if (!requestToken) {
|
||||
user.value = null
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
// 路由守卫、App 初始化和认证同步可能同时触发,复用同一个请求。
|
||||
if (fetchCurrentUserPromise && fetchCurrentUserToken === requestToken) {
|
||||
return fetchCurrentUserPromise
|
||||
}
|
||||
|
||||
// 后端暂时不可用时,不要让每一次导航都重新等待全局请求超时。
|
||||
if (
|
||||
lastCurrentUserFailureToken === requestToken &&
|
||||
Date.now() - lastCurrentUserFailureAt < CURRENT_USER_FAILURE_BACKOFF_MS
|
||||
) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
fetchCurrentUserToken = requestToken
|
||||
const requestAuthStateVersion = authStateVersion
|
||||
const request = (async () => {
|
||||
try {
|
||||
const userInfo = await authApi.getCurrentUser()
|
||||
if (requestAuthStateVersion !== authStateVersion || !token.value) {
|
||||
return null
|
||||
}
|
||||
user.value = userInfo
|
||||
resetCurrentUserFailure()
|
||||
return userInfo
|
||||
} catch (err: unknown) {
|
||||
log.error('Failed to fetch user info', err)
|
||||
if (requestAuthStateVersion !== authStateVersion) {
|
||||
return null
|
||||
}
|
||||
syncToken()
|
||||
if (requestAuthStateVersion !== authStateVersion) {
|
||||
if (!token.value) user.value = null
|
||||
return null
|
||||
}
|
||||
if (!token.value) {
|
||||
user.value = null
|
||||
} else {
|
||||
lastCurrentUserFailureAt = Date.now()
|
||||
lastCurrentUserFailureToken = token.value
|
||||
}
|
||||
// 保留登录状态;短暂退避后允许再次校验。
|
||||
log.info('Keeping session despite error, as per user requirement')
|
||||
return null
|
||||
} finally {
|
||||
if (fetchCurrentUserPromise === request) {
|
||||
fetchCurrentUserPromise = null
|
||||
fetchCurrentUserToken = null
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
fetchCurrentUserPromise = request
|
||||
return request
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
const storedToken = apiClient.getToken()
|
||||
if (storedToken) {
|
||||
token.value = storedToken
|
||||
// 即使获取用户信息失败,也保留 token
|
||||
// 只有 401 错误才表示 token 真正失效
|
||||
syncToken()
|
||||
if (token.value && !user.value) {
|
||||
// 即使获取用户信息失败,也保留 token。
|
||||
await fetchCurrentUser()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user