From 90434ee39b98d2272080dda9771c0a9bbcacdd00 Mon Sep 17 00:00:00 2001 From: Hungerdream <1710233908@qq.com> Date: Thu, 30 Jul 2026 18:14:09 +0800 Subject: [PATCH] feat(frontend): unify authenticated requests with 401 session handling - add utils/session.ts: on 401 clear auth state and redirect to SSO or /login with return path; singleton promise prevents duplicate redirects; typed AuthenticationRequiredError for callers - add api/authRequest.ts: shared generic authRequest with token injection, response parsing and 401 hook - migrate businessLine/containerService/delivery/machine/subsystemAuth/taskLog api modules to the shared helper, removing six duplicated implementations; keep getToken only for EventSource query-param auth - Catalog: silence session-expiry error in instance-name precheck; prefer mysql delivery target by exact template name (matches backend DELIVERY_MYSQL_TEMPLATE_NAME default) --- frontend/src/api/authRequest.ts | 38 ++++++++++++++++++ frontend/src/api/businessLine.ts | 54 +++++++------------------- frontend/src/api/containerService.ts | 33 +--------------- frontend/src/api/delivery.ts | 32 +-------------- frontend/src/api/machine.ts | 33 +--------------- frontend/src/api/subsystemAuth.ts | 33 +--------------- frontend/src/api/taskLog.ts | 32 +-------------- frontend/src/utils/session.ts | 42 ++++++++++++++++++++ frontend/src/views/service/Catalog.vue | 2 + 9 files changed, 100 insertions(+), 199 deletions(-) create mode 100644 frontend/src/api/authRequest.ts create mode 100644 frontend/src/utils/session.ts diff --git a/frontend/src/api/authRequest.ts b/frontend/src/api/authRequest.ts new file mode 100644 index 0000000..b62cbeb --- /dev/null +++ b/frontend/src/api/authRequest.ts @@ -0,0 +1,38 @@ +import { getToken } from '@/utils/auth' +import { handleUnauthorized } from '@/utils/session' + +type JsonObject = Record + +export async function authRequest(path: string, init: RequestInit = {}): Promise { + const token = getToken() + const response = await fetch(path, { + ...init, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...init.headers, + }, + }) + const text = await response.text() + const data = parseResponseBody(text) + if (!response.ok) { + if (response.status === 401) { + return handleUnauthorized() + } + const message = data?.error || data?.message || text || `HTTP ${response.status}` + throw new Error(message) + } + return (data || {}) as T +} + +function parseResponseBody(text: string): JsonObject { + if (!text.trim()) { + return {} + } + try { + return JSON.parse(text) as JsonObject + } catch { + return { error: text } + } +} diff --git a/frontend/src/api/businessLine.ts b/frontend/src/api/businessLine.ts index 1cbd27e..61d33e2 100644 --- a/frontend/src/api/businessLine.ts +++ b/frontend/src/api/businessLine.ts @@ -1,4 +1,4 @@ -import { getToken } from '@/utils/auth' +import { authRequest } from '@/api/authRequest' export interface BusinessLine { id: number @@ -21,41 +21,31 @@ export interface SinaOrganization { export const businessLineApi = { async listMine(): Promise { - const token = getToken() - const response = await fetch('/auth/api/v1/business-lines', { - headers: { - Accept: 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }) - const data = await response.json().catch(() => ({})) - if (!response.ok) { - throw new Error(data.error || `HTTP ${response.status}`) - } + const data = await authRequest('/auth/api/v1/business-lines') return Array.isArray(data.items) ? data.items : [] }, async listAll(): Promise { - const data = await request('/auth/api/v1/business-lines/all') + const data = await authRequest('/auth/api/v1/business-lines/all') return Array.isArray(data.items) ? data.items : [] }, async create(name: string): Promise { - return request('/auth/api/v1/business-lines', { + return authRequest('/auth/api/v1/business-lines', { method: 'POST', body: JSON.stringify({ name }), }) }, async update(id: number, name: string): Promise { - return request(`/auth/api/v1/business-lines/${id}`, { + return authRequest(`/auth/api/v1/business-lines/${id}`, { method: 'PUT', body: JSON.stringify({ name }), }) }, async remove(id: number): Promise { - await request(`/auth/api/v1/business-lines/${id}`, { + await authRequest(`/auth/api/v1/business-lines/${id}`, { method: 'DELETE', }) }, @@ -65,14 +55,14 @@ export const businessLineApi = { target_user_id: number target_business_line_id: number }): Promise { - await request('/auth/api/v1/business-lines/authorizations', { + await authRequest('/auth/api/v1/business-lines/authorizations', { method: 'POST', body: JSON.stringify(payload), }) }, async listWayneNamespaces(): Promise { - const data = await request('/auth/api/v1/wayne/namespaces') + const data = await authRequest('/auth/api/v1/wayne/namespaces') const items = Array.isArray(data.data?.list) ? data.data.list : [] return items.map((item: any) => ({ id: Number(item.id), @@ -82,12 +72,12 @@ export const businessLineApi = { }, async listMappedWayneNamespaces(businessLineId: number): Promise { - const data = await request(`/auth/api/v1/business-lines/${businessLineId}/wayne-namespaces`) + const data = await authRequest(`/auth/api/v1/business-lines/${businessLineId}/wayne-namespaces`) return Array.isArray(data.items) ? data.items : [] }, async replaceMappedWayneNamespaces(businessLineId: number, namespaces: WayneNamespace[]): Promise { - await request(`/auth/api/v1/business-lines/${businessLineId}/wayne-namespaces`, { + await authRequest(`/auth/api/v1/business-lines/${businessLineId}/wayne-namespaces`, { method: 'PUT', body: JSON.stringify({ namespaces }), }) @@ -99,37 +89,19 @@ export const businessLineApi = { params.set('keyword', keyword.trim()) } const suffix = params.toString() ? `?${params.toString()}` : '' - const data = await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organizations${suffix}`) + const data = await authRequest(`/auth/api/v1/business-lines/${businessLineId}/sina-organizations${suffix}`) return Array.isArray(data.items) ? data.items : [] }, async listMappedSinaOrganizations(businessLineId: number): Promise { - const data = await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`) + const data = await authRequest(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`) return Array.isArray(data.items) ? data.items : [] }, async replaceMappedSinaOrganizations(businessLineId: number, organizations: SinaOrganization[]): Promise { - await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`, { + await authRequest(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`, { method: 'PUT', body: JSON.stringify({ organizations }), }) }, } - -async function request(path: string, init: RequestInit = {}) { - const token = getToken() - const response = await fetch(path, { - ...init, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...init.headers, - }, - }) - const data = await response.json().catch(() => ({})) - if (!response.ok) { - throw new Error(data.error || `HTTP ${response.status}`) - } - return data -} diff --git a/frontend/src/api/containerService.ts b/frontend/src/api/containerService.ts index 647ed81..19f48c5 100644 --- a/frontend/src/api/containerService.ts +++ b/frontend/src/api/containerService.ts @@ -1,4 +1,4 @@ -import { getToken } from '@/utils/auth' +import { authRequest } from '@/api/authRequest' export interface ContainerServiceSummary { businessLineId: number @@ -102,34 +102,3 @@ export const containerServiceApi = { } }, } - -async function authRequest(path: string, init: RequestInit = {}) { - const token = getToken() - const response = await fetch(path, { - ...init, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...init.headers, - }, - }) - const text = await response.text() - const data = parseResponseBody(text) - if (!response.ok) { - const message = data?.error || data?.message || text || `HTTP ${response.status}` - throw new Error(message) - } - return data || {} -} - -function parseResponseBody(text: string) { - if (!text.trim()) { - return {} - } - try { - return JSON.parse(text) - } catch { - return { error: text } - } -} diff --git a/frontend/src/api/delivery.ts b/frontend/src/api/delivery.ts index f515306..f1e8a02 100644 --- a/frontend/src/api/delivery.ts +++ b/frontend/src/api/delivery.ts @@ -1,4 +1,5 @@ import { getToken } from '@/utils/auth' +import { authRequest } from '@/api/authRequest' export interface DeliveryTarget { id: number @@ -213,34 +214,3 @@ function createIdempotencyKey(payload: CreateMySQLDeliveryPayload) { Date.now(), ].join(':') } - -async function authRequest(path: string, init: RequestInit = {}) { - const token = getToken() - const response = await fetch(path, { - ...init, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...init.headers, - }, - }) - const text = await response.text() - const data = parseResponseBody(text) - if (!response.ok) { - const message = data?.error || data?.message || text || `HTTP ${response.status}` - throw new Error(message) - } - return data || {} -} - -function parseResponseBody(text: string) { - if (!text.trim()) { - return {} - } - try { - return JSON.parse(text) - } catch { - return { error: text } - } -} diff --git a/frontend/src/api/machine.ts b/frontend/src/api/machine.ts index 53fe66a..7839816 100644 --- a/frontend/src/api/machine.ts +++ b/frontend/src/api/machine.ts @@ -1,4 +1,4 @@ -import { getToken } from '@/utils/auth' +import { authRequest } from '@/api/authRequest' export interface MachineOverview { total: number @@ -114,34 +114,3 @@ export const machineApi = { await authRequest('/auth/api/v1/machines/sync', { method: 'POST' }) }, } - -async function authRequest(path: string, init: RequestInit = {}) { - const token = getToken() - const response = await fetch(path, { - ...init, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...init.headers, - }, - }) - const text = await response.text() - const data = parseResponseBody(text) - if (!response.ok) { - const message = data?.error || data?.message || text || `HTTP ${response.status}` - throw new Error(message) - } - return data || {} -} - -function parseResponseBody(text: string) { - if (!text.trim()) { - return {} - } - try { - return JSON.parse(text) - } catch { - return { error: text } - } -} diff --git a/frontend/src/api/subsystemAuth.ts b/frontend/src/api/subsystemAuth.ts index bda2ba6..5febeb7 100644 --- a/frontend/src/api/subsystemAuth.ts +++ b/frontend/src/api/subsystemAuth.ts @@ -1,4 +1,4 @@ -import { getToken } from '@/utils/auth' +import { authRequest } from '@/api/authRequest' export interface SubsystemAuthSystem { key: string @@ -110,34 +110,3 @@ export const subsystemAuthApi = { }) }, } - -async function authRequest(path: string, init: RequestInit = {}) { - const token = getToken() - const response = await fetch(path, { - ...init, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...init.headers, - }, - }) - const text = await response.text() - const data = parseResponseBody(text) - if (!response.ok) { - const message = data?.error || data?.message || text || `HTTP ${response.status}` - throw new Error(message) - } - return data || {} -} - -function parseResponseBody(text: string) { - if (!text.trim()) { - return {} - } - try { - return JSON.parse(text) - } catch { - return { error: text } - } -} diff --git a/frontend/src/api/taskLog.ts b/frontend/src/api/taskLog.ts index c4505d9..934007e 100644 --- a/frontend/src/api/taskLog.ts +++ b/frontend/src/api/taskLog.ts @@ -1,4 +1,5 @@ import { getToken } from '@/utils/auth' +import { authRequest } from '@/api/authRequest' export interface TaskLogSummary { id: string @@ -71,37 +72,6 @@ export const taskLogApi = { }, } -async function authRequest(path: string, init: RequestInit = {}) { - const token = getToken() - const response = await fetch(path, { - ...init, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...init.headers, - }, - }) - const text = await response.text() - const data = parseResponseBody(text) - if (!response.ok) { - const message = data?.error || data?.message || text || `HTTP ${response.status}` - throw new Error(message) - } - return data || {} -} - -function parseResponseBody(text: string) { - if (!text.trim()) { - return {} - } - try { - return JSON.parse(text) - } catch { - return { error: text } - } -} - export interface TaskLogStreamCallbacks { onInit?: (lines: TaskLogLine[]) => void onUpdate?: (lines: TaskLogLine[]) => void diff --git a/frontend/src/utils/session.ts b/frontend/src/utils/session.ts new file mode 100644 index 0000000..8355ed1 --- /dev/null +++ b/frontend/src/utils/session.ts @@ -0,0 +1,42 @@ +import { authApi } from '@/api/auth' +import { useAuthStore } from '@/stores/auth' +import { redirectToSSO } from '@/utils/sso' + +let unauthorizedRedirect: Promise | null = null + +export class AuthenticationRequiredError extends Error { + constructor() { + super('登录状态已失效,正在重新认证') + this.name = 'AuthenticationRequiredError' + } +} + +export function isAuthenticationRequiredError(error: unknown): error is AuthenticationRequiredError { + return error instanceof AuthenticationRequiredError +} + +export async function handleUnauthorized(): Promise { + if (!unauthorizedRedirect) { + unauthorizedRedirect = redirectAfterClearingSession() + } + await unauthorizedRedirect + throw new AuthenticationRequiredError() +} + +async function redirectAfterClearingSession(): Promise { + useAuthStore().clearAuth() + + try { + const { data } = await authApi.getConfig() + if (data.sso_enabled) { + redirectToSSO() + return + } + } catch { + redirectToSSO() + return + } + + const redirect = `${window.location.pathname}${window.location.search}${window.location.hash}` + window.location.assign(`/login?redirect=${encodeURIComponent(redirect)}`) +} diff --git a/frontend/src/views/service/Catalog.vue b/frontend/src/views/service/Catalog.vue index 6f90768..ebc6b1f 100644 --- a/frontend/src/views/service/Catalog.vue +++ b/frontend/src/views/service/Catalog.vue @@ -522,6 +522,7 @@ import { deliveryApi, type DeliveryMountPath, type DeliveryTarget, type Deployme import { useAuthStore } from '@/stores/auth' import { useBusinessLineStore } from '@/stores/businessLine' import { useBusinessLineMockProfile } from '@/utils/businessLineMock' +import { isAuthenticationRequiredError } from '@/utils/session' type FlowView = 'config' | 'execution' | 'result' type StepState = 'pending' | 'running' | 'done' | 'failed' @@ -1295,6 +1296,7 @@ async function precheck() { } } catch (error) { precheckPassed.value = false + if (isAuthenticationRequiredError(error)) return ElMessage.error(error instanceof Error ? `实例名称可用性检查失败:${error.message}` : '实例名称可用性检查失败') return }