Files
skt-vuetify-templates/src/services/interceptors.ts
T

123 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type AxiosError, AxiosHeaders, type AxiosInstance } from 'axios'
import { extractErrorMessage, normalizeError } from './error'
import { emitHttpError } from './http-error'
import { emitHttpToast } from './http-toast'
import { emitForceLogout } from './session'
import { tokenService } from './token'
// Axios 攔截器
//
// 設計重點:
// - Request:自動注入 token(從 localStorage 讀取)
// - 使用 tokenService 作為單一來源,避免 interceptor 直接 import Pinia store 造成循環依賴
// store(auth) -> services(userApi) -> httpClient -> interceptors -> store(auth)
// - Response:統一處理 HTTP 錯誤(目前示範 401/403/500
// - 使用 normalizeError 將錯誤轉成 ApiRequestError
//
// 注意:
// - Store 仍然是唯一負責「寫入/清除 token」的地方(login/logout
// - Interceptor 只負責「讀取 token 並附加到 request」
export function setupInterceptors (client: AxiosInstance) {
// Request: 自動注入 token
client.interceptors.request.use(
(config) => {
const token = tokenService.getToken()
const url = config.url ?? ''
const shouldAttachToken = !url.includes('/Auth/login')
if (token && shouldAttachToken) {
const headers = AxiosHeaders.from(config.headers ?? {})
headers.set('Authorization', `Bearer ${token}`)
config.headers = headers
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response: 統一錯誤處理
client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
const normalized = normalizeError(error)
// 取消請求不做全域錯誤導頁
if (error.code === 'ERR_CANCELED') {
return Promise.reject(normalized)
}
const requestUrl = error.config?.url ?? ''
const isLoginRequest = requestUrl.includes('/Auth/login')
const silentToast = Boolean(error.config?.meta?.silentToast)
// 統一處理 HTTP 狀態碼錯誤
const status = error.response?.status
switch (status) {
// 401 Unauthorized
case 401: {
// 不是所有 401 都代表「token 過期」:
// - 登入失敗通常也會是 401,但不應觸發全域登出流程
// 這裡以「是否帶 Authorization header」作為判斷依據
{
const url = error.config?.url ?? ''
if (url.includes('/Auth/login')) break
const requestHeaders = AxiosHeaders.from(error.config?.headers ?? {})
const hasAuthHeader = Boolean(requestHeaders.get('Authorization'))
if (hasAuthHeader) {
tokenService.clearToken()
const backendMessage = extractErrorMessage(error.response?.data)
emitForceLogout({ message: backendMessage })
}
}
break
}
// 403 Forbidden
case 403: {
emitHttpError({ status, message: normalized.message })
break
}
// 404 Not FoundAPI 端點不存在/被移除)
case 404: {
emitHttpError({ status, message: normalized.message })
break
}
// 500 Internal Server Error
case 500: {
// 500 通常是「單一 API 失敗」:交由呼叫端決定 UI(snackbar/區塊錯誤/重試)
// 避免同一頁多支 API 時,其中一支 500 就把整個頁面導走
break
}
// 503 Service Unavailable
case 503: {
// 503 通常對使用者來說就是「系統維護/暫時無法使用」
emitHttpError({ status, message: normalized.message })
break
}
default:
// 無 response status 時,多半是網路/跨網域/連線問題:
// 交由呼叫端決定 UI(snackbar/區塊錯誤/重試),避免全域導頁打斷使用者操作
}
const shouldToast =
!silentToast &&
!isLoginRequest &&
status !== 401 &&
status !== 403 &&
status !== 404 &&
status !== 503 &&
(status === 500 || !status || (typeof status === 'number' && status >= 500))
if (shouldToast) {
emitHttpToast({
level: status ? 'error' : 'warning',
message: normalized.message
})
}
return Promise.reject(normalized)
}
)
}