feat: complete admin API integration

This commit is contained in:
2026-07-31 13:43:16 +08:00
parent bb09c7d6a0
commit d0f94d95d7
24 changed files with 1834 additions and 113 deletions
+154
View File
@@ -0,0 +1,154 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
post: vi.fn(),
listCurrentPlatformPermissionCodes: vi.fn(),
refreshHandler: null as null | (() => Promise<string>),
}))
vi.mock('@/api/http', () => ({
http: { post: mocks.post },
registerAuthRefreshHandler: (handler: () => Promise<string>) => { mocks.refreshHandler = handler },
unwrapApiResponse: <T>(response: { code: string; message: string; data: T }) => {
if (response.code !== '0') throw new Error(response.message)
return response.data
},
getApiErrorDetails: (error: { code?: string; message?: string }) => ({
message: error.message || '接口请求失败',
code: error.code,
}),
}))
vi.mock('@/api/platformIam', () => ({
listCurrentPlatformPermissionCodes: mocks.listCurrentPlatformPermissionCodes,
}))
import { useAuthStore } from './auth'
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
}
}
function token(overrides: Record<string, unknown> = {}) {
const claims = {
user_id: '1000001',
username: 'platform_admin',
app_code: 'PLATFORM',
roles: ['PLATFORM_ADMIN'],
permissions: ['platform:warehouse:list', 'platform:order:list'],
exp: Math.floor(Date.now() / 1000) + 1800,
...overrides,
}
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url')
return `header.${payload}.signature`
}
function loginResponse(accessToken = token(), refreshToken = 'refresh-next') {
return {
code: '0',
message: 'success',
data: {
tokenType: 'Bearer',
accessToken,
expiresIn: 1800,
refreshToken,
refreshExpiresIn: 604800,
},
}
}
describe('admin auth session', () => {
beforeEach(() => {
vi.stubGlobal('localStorage', createMemoryStorage())
setActivePinia(createPinia())
mocks.post.mockReset()
mocks.listCurrentPlatformPermissionCodes.mockReset()
mocks.refreshHandler = null
})
it('intersects JWT permissions with the current authenticated permission snapshot', async () => {
mocks.post.mockResolvedValue(loginResponse())
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const auth = useAuthStore()
await auth.login('warehouse_operator', 'password')
expect(auth.isAuthenticated).toBe(true)
expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list'])
expect(auth.permissionResources).toEqual([])
expect(localStorage.getItem('cc-admin-permission-codes'))
.toBe('["platform:warehouse:list"]')
})
it('rejects login and clears the session when the current permission snapshot is unavailable', async () => {
mocks.post.mockResolvedValue(loginResponse())
mocks.listCurrentPlatformPermissionCodes.mockRejectedValue({ code: 'S0503', message: 'IAM 服务不可用' })
const auth = useAuthStore()
await expect(auth.login('platform_admin', 'password')).rejects.toMatchObject({ code: 'S0503' })
expect(auth.isAuthenticated).toBe(false)
expect(localStorage.getItem('cc-admin-token')).toBeNull()
expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull()
})
it('revokes an issued token pair when the permission snapshot prevents login completion', async () => {
mocks.post
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce({ code: '0', message: 'success', data: null })
mocks.listCurrentPlatformPermissionCodes.mockRejectedValue({ code: 'S0503', message: 'IAM 服务不可用' })
const auth = useAuthStore()
await expect(auth.login('platform_admin', 'password')).rejects.toMatchObject({ code: 'S0503' })
await Promise.resolve()
expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/logout', { refreshToken: 'refresh-next' }, {
headers: { Authorization: expect.stringContaining('Bearer header.') },
})
})
it('restores an expired access session with the persisted refresh token', async () => {
localStorage.setItem('cc-admin-token', token({ exp: Math.floor(Date.now() / 1000) - 30 }))
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
mocks.post.mockResolvedValue(loginResponse(token(), 'refresh-rotated'))
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const auth = useAuthStore()
await expect(auth.restoreSession()).resolves.toBe(true)
expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' })
expect(auth.refreshToken).toBe('refresh-rotated')
expect(auth.isAuthenticated).toBe(true)
})
it('does not let an in-flight refresh restore a session after logout', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
let resolveRefresh: ((value: ReturnType<typeof loginResponse>) => void) | undefined
mocks.post.mockImplementation((url: string) => {
if (url === '/auth/refresh') {
return new Promise((resolve) => { resolveRefresh = resolve })
}
return Promise.resolve({ code: '0', message: 'success', data: null })
})
const auth = useAuthStore()
const refreshing = auth.refreshSession()
await Promise.resolve()
await auth.logout()
resolveRefresh?.(loginResponse(token(), 'refresh-rotated'))
await expect(refreshing).rejects.toThrow('登录会话已被后续操作替代')
expect(auth.isAuthenticated).toBe(false)
expect(auth.refreshToken).toBe('')
expect(localStorage.getItem('cc-admin-token')).toBeNull()
})
})
+79 -26
View File
@@ -1,8 +1,13 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { CurrentUser } from '@chongchong/shared'
import { http, registerAuthRefreshHandler, unwrapApiResponse, type ApiEnvelope } from '@/api/http'
import { listPlatformPermissions, type PlatformPermissionResource } from '@/api/platformIam'
import {
http,
registerAuthRefreshHandler,
unwrapApiResponse,
type ApiEnvelope,
} from '@/api/http'
import { listCurrentPlatformPermissionCodes, type PlatformPermissionResource } from '@/api/platformIam'
import { decodeJwtClaims } from '@/utils/jwt'
const TOKEN_KEY = 'cc-admin-token'
@@ -17,10 +22,13 @@ interface LoginResponse {
refreshExpiresIn: number
}
class ExpiredAccessTokenError extends Error {}
class SupersededSessionError extends Error {}
function sessionFromToken(token: string) {
const claims = decodeJwtClaims(token)
if (claims.app_code !== 'PLATFORM') throw new Error('账号不属于当前应用端')
if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('登录状态已过期,请重新登录')
if (claims.exp && claims.exp * 1000 <= Date.now()) throw new ExpiredAccessTokenError('登录状态已过期')
return {
user: {
id: String(claims.user_id ?? ''),
@@ -39,6 +47,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
const permissions = ref<string[]>([])
const permissionResources = ref<PlatformPermissionResource[]>([])
const isServerSession = ref(false)
let sessionGeneration = 0
if (token.value) {
try {
@@ -48,20 +57,34 @@ export const useAuthStore = defineStore('admin-auth', () => {
user.value = session.user
permissions.value = session.permissions.filter((code) => cachedPermissions.includes(code))
isServerSession.value = true
} catch {
} catch (error) {
token.value = ''
refreshToken.value = ''
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(PERMISSION_CODES_KEY)
if (!(error instanceof ExpiredAccessTokenError) || !refreshToken.value) {
refreshToken.value = ''
localStorage.removeItem(REFRESH_TOKEN_KEY)
}
}
}
const isAuthenticated = computed(() => Boolean(token.value))
const permissionCodes = computed<ReadonlySet<string>>(() => new Set(permissions.value))
async function applySession(result: LoginResponse) {
function ensureCurrentGeneration(expectedGeneration: number) {
if (expectedGeneration !== sessionGeneration) {
throw new SupersededSessionError('登录会话已被后续操作替代')
}
}
function applyPermissionCodes(codes: string[]) {
permissions.value = codes
localStorage.setItem(PERMISSION_CODES_KEY, JSON.stringify(codes))
}
async function applySession(result: LoginResponse, expectedGeneration: number) {
const session = sessionFromToken(result.accessToken)
ensureCurrentGeneration(expectedGeneration)
token.value = result.accessToken
refreshToken.value = result.refreshToken
user.value = session.user
@@ -69,19 +92,19 @@ export const useAuthStore = defineStore('admin-auth', () => {
localStorage.setItem(TOKEN_KEY, token.value)
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken.value)
try {
permissionResources.value = await listPlatformPermissions()
const activeResourceCodes = new Set(permissionResources.value
.filter((resource) => resource.status === 'ACTIVE')
.map((resource) => resource.permissionCode))
permissions.value = session.permissions.filter((code) => activeResourceCodes.has(code))
localStorage.setItem(PERMISSION_CODES_KEY, JSON.stringify(permissions.value))
const activePermissionCodes = await listCurrentPlatformPermissionCodes()
ensureCurrentGeneration(expectedGeneration)
permissionResources.value = []
const activePermissionCodeSet = new Set(activePermissionCodes)
applyPermissionCodes(session.permissions.filter((code) => activePermissionCodeSet.has(code)))
} catch (error) {
clearSession()
ensureCurrentGeneration(expectedGeneration)
throw error
}
}
function clearSession() {
sessionGeneration += 1
token.value = ''
refreshToken.value = ''
user.value = null
@@ -93,44 +116,73 @@ export const useAuthStore = defineStore('admin-auth', () => {
localStorage.removeItem(PERMISSION_CODES_KEY)
}
async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) {
await http.post('/auth/logout', { refreshToken: refreshTokenToRevoke }, {
headers: { Authorization: `Bearer ${accessToken}` },
})
}
async function login(account: string, password: string) {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/login',
{ username: account, password },
)
const result = unwrapApiResponse<LoginResponse>(response)
await applySession(result)
const expectedGeneration = ++sessionGeneration
try {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/login',
{ username: account, password },
)
const result = unwrapApiResponse<LoginResponse>(response)
await applySession(result, expectedGeneration)
} catch (error) {
if (expectedGeneration === sessionGeneration) {
const issuedAccessToken = token.value
const issuedRefreshToken = refreshToken.value
clearSession()
if (issuedAccessToken && issuedRefreshToken) {
void revokeTokenPair(issuedAccessToken, issuedRefreshToken).catch(() => undefined)
}
}
throw error
}
}
async function refreshSession() {
if (!refreshToken.value) throw new Error('刷新令牌不存在,请重新登录')
const expectedGeneration = sessionGeneration
try {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/refresh',
{ refreshToken: refreshToken.value },
)
const result = unwrapApiResponse<LoginResponse>(response)
await applySession(result)
await applySession(result, expectedGeneration)
} catch (error) {
clearSession()
if (expectedGeneration === sessionGeneration) clearSession()
throw error
}
}
async function restoreSession() {
if (isAuthenticated.value) return true
if (!refreshToken.value) return false
try {
await refreshSession()
return true
} catch {
return false
}
}
registerAuthRefreshHandler(async () => {
await refreshSession()
if (!token.value) throw new Error('登录状态刷新失败')
return token.value
})
function logout() {
async function logout() {
const accessToken = token.value
const tokenToRevoke = refreshToken.value
clearSession()
if (accessToken && tokenToRevoke) {
void http.post('/auth/logout', { refreshToken: tokenToRevoke }, {
headers: { Authorization: `Bearer ${accessToken}` },
}).catch(() => undefined)
await revokeTokenPair(accessToken, tokenToRevoke)
}
}
@@ -145,6 +197,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
isAuthenticated,
login,
refreshSession,
restoreSession,
logout,
}
})