290 lines
6.9 KiB
JavaScript
290 lines
6.9 KiB
JavaScript
import {
|
||
buildBearerHeaders,
|
||
notifyAuthSessionExpired,
|
||
readAuthAccessToken
|
||
} from '../utils/authSessionStorage.js'
|
||
|
||
const API_BASE_STORAGE_KEY = 'x-financial-api-base-url'
|
||
|
||
function isHeaderValueSafe(value) {
|
||
const normalized = String(value || '').trim()
|
||
if (!normalized) {
|
||
return false
|
||
}
|
||
|
||
for (let index = 0; index < normalized.length; index += 1) {
|
||
const codePoint = normalized.charCodeAt(index)
|
||
if (codePoint > 255 || codePoint === 10 || codePoint === 13 || codePoint === 0) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
export function pickSafeHeaderValue(value, fallback = '') {
|
||
const normalized = String(value || '').trim()
|
||
if (isHeaderValueSafe(normalized)) {
|
||
return normalized
|
||
}
|
||
|
||
const fallbackValue = String(fallback || '').trim()
|
||
if (isHeaderValueSafe(fallbackValue)) {
|
||
return fallbackValue
|
||
}
|
||
|
||
return ''
|
||
}
|
||
|
||
function normalizeApiBaseUrl(value) {
|
||
return String(value || '/api/v1').replace(/\/$/, '')
|
||
}
|
||
|
||
function isLoopbackHost(hostname) {
|
||
const normalized = String(hostname || '').trim().toLowerCase()
|
||
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '0.0.0.0' || normalized === '::1'
|
||
}
|
||
|
||
function resolveBrowserReachableApiBaseUrl(value) {
|
||
const normalized = normalizeApiBaseUrl(value)
|
||
|
||
if (typeof window === 'undefined') {
|
||
return normalized
|
||
}
|
||
|
||
try {
|
||
const apiUrl = new URL(normalized)
|
||
const browserHost = window.location.hostname
|
||
|
||
if (isLoopbackHost(apiUrl.hostname) && browserHost && !isLoopbackHost(browserHost)) {
|
||
apiUrl.hostname = browserHost
|
||
return normalizeApiBaseUrl(apiUrl.toString())
|
||
}
|
||
} catch {
|
||
return normalized
|
||
}
|
||
|
||
return normalized
|
||
}
|
||
|
||
function readStoredApiBaseUrl() {
|
||
if (typeof window === 'undefined') {
|
||
return ''
|
||
}
|
||
|
||
return resolveBrowserReachableApiBaseUrl(window.localStorage.getItem(API_BASE_STORAGE_KEY) || '')
|
||
}
|
||
|
||
let runtimeApiBaseUrl = normalizeApiBaseUrl('/api/v1')
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window.localStorage.removeItem(API_BASE_STORAGE_KEY)
|
||
}
|
||
|
||
export function setRuntimeApiBaseUrl(value) {
|
||
runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value)
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window.localStorage.setItem(API_BASE_STORAGE_KEY, runtimeApiBaseUrl)
|
||
}
|
||
}
|
||
|
||
export function getRuntimeApiBaseUrl() {
|
||
return runtimeApiBaseUrl
|
||
}
|
||
|
||
function buildUrl(path) {
|
||
if (!path.startsWith('/')) {
|
||
return `${runtimeApiBaseUrl}/${path}`
|
||
}
|
||
|
||
return `${runtimeApiBaseUrl}${path}`
|
||
}
|
||
|
||
function formatValidationLocation(loc) {
|
||
if (!Array.isArray(loc)) {
|
||
return ''
|
||
}
|
||
|
||
return loc
|
||
.filter((item) => item !== 'body')
|
||
.map((item) => String(item || '').trim())
|
||
.filter(Boolean)
|
||
.join('.')
|
||
}
|
||
|
||
function resolveErrorMessage(payload, fallback = '接口请求失败,请稍后重试。') {
|
||
const detail = payload?.detail
|
||
|
||
if (typeof detail === 'string' && detail.trim()) {
|
||
return detail.trim()
|
||
}
|
||
|
||
if (Array.isArray(detail) && detail.length) {
|
||
const messages = detail
|
||
.map((item) => {
|
||
if (typeof item === 'string' && item.trim()) {
|
||
return item.trim()
|
||
}
|
||
|
||
if (!item || typeof item !== 'object') {
|
||
return ''
|
||
}
|
||
|
||
const message = String(item.msg || item.message || '').trim()
|
||
const location = formatValidationLocation(item.loc)
|
||
|
||
if (location && message) {
|
||
return `${location}: ${message}`
|
||
}
|
||
|
||
return message
|
||
})
|
||
.filter(Boolean)
|
||
|
||
if (messages.length) {
|
||
return messages.join(';')
|
||
}
|
||
}
|
||
|
||
if (detail && typeof detail === 'object') {
|
||
const message = String(detail.message || detail.msg || '').trim()
|
||
if (message) {
|
||
return message
|
||
}
|
||
}
|
||
|
||
if (typeof payload?.message === 'string' && payload.message.trim()) {
|
||
return payload.message.trim()
|
||
}
|
||
|
||
return fallback
|
||
}
|
||
|
||
function sanitizeHeaders(headers) {
|
||
const nextHeaders = {}
|
||
|
||
Object.entries(headers || {}).forEach(([key, value]) => {
|
||
if (typeof value === 'undefined' || value === null) {
|
||
return
|
||
}
|
||
|
||
const normalizedValue = String(value).trim()
|
||
if (!normalizedValue) {
|
||
return
|
||
}
|
||
|
||
if (!isHeaderValueSafe(normalizedValue)) {
|
||
return
|
||
}
|
||
|
||
nextHeaders[key] = normalizedValue
|
||
})
|
||
|
||
return nextHeaders
|
||
}
|
||
|
||
function buildApiResponseError(response, payload, { auth, handleUnauthorized }) {
|
||
const error = new Error(resolveErrorMessage(payload))
|
||
error.status = response.status
|
||
if (response.status === 401 && auth && handleUnauthorized && readAuthAccessToken()) {
|
||
error.code = 'AUTH_SESSION_EXPIRED'
|
||
notifyAuthSessionExpired()
|
||
}
|
||
return error
|
||
}
|
||
|
||
export async function apiRequest(path, options = {}) {
|
||
const {
|
||
auth = true,
|
||
handleUnauthorized = true,
|
||
contentType = 'application/json',
|
||
responseType = 'json',
|
||
headers: customHeaders,
|
||
timeoutMs = 0,
|
||
timeoutMessage = '',
|
||
...fetchOptions
|
||
} = options
|
||
|
||
const headers = sanitizeHeaders(customHeaders || {})
|
||
if (auth) {
|
||
Object.keys(headers).forEach((key) => {
|
||
if (key.toLowerCase() === 'authorization') {
|
||
delete headers[key]
|
||
}
|
||
})
|
||
Object.assign(headers, buildBearerHeaders())
|
||
}
|
||
|
||
if (contentType !== null && typeof headers['Content-Type'] === 'undefined') {
|
||
headers['Content-Type'] = contentType
|
||
}
|
||
|
||
let response
|
||
let timeoutId = 0
|
||
let requestOptions = {
|
||
...fetchOptions,
|
||
headers
|
||
}
|
||
|
||
if (!fetchOptions.signal && Number(timeoutMs) > 0 && typeof AbortController !== 'undefined') {
|
||
const controller = new AbortController()
|
||
timeoutId = globalThis.setTimeout(() => controller.abort(), Number(timeoutMs))
|
||
requestOptions = {
|
||
...requestOptions,
|
||
signal: controller.signal
|
||
}
|
||
}
|
||
|
||
try {
|
||
response = await fetch(buildUrl(path), requestOptions)
|
||
} catch (error) {
|
||
if (timeoutId) {
|
||
globalThis.clearTimeout(timeoutId)
|
||
}
|
||
if (error?.name === 'AbortError') {
|
||
const timeoutError = new Error(String(timeoutMessage || '').trim() || '接口请求超时,请稍后重试。')
|
||
timeoutError.code = 'REQUEST_TIMEOUT'
|
||
throw timeoutError
|
||
}
|
||
if (String(error?.message || '').includes('ByteString')) {
|
||
throw new Error('当前登录用户信息包含浏览器不支持的请求头字符,请重新登录后重试。')
|
||
}
|
||
|
||
throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。')
|
||
}
|
||
|
||
if (timeoutId) {
|
||
globalThis.clearTimeout(timeoutId)
|
||
}
|
||
|
||
if (responseType === 'blob') {
|
||
if (!response.ok) {
|
||
let payload = null
|
||
|
||
try {
|
||
payload = await response.json()
|
||
} catch {
|
||
payload = null
|
||
}
|
||
|
||
throw buildApiResponseError(response, payload, { auth, handleUnauthorized })
|
||
}
|
||
|
||
return response.blob()
|
||
}
|
||
|
||
let payload = null
|
||
try {
|
||
payload = await response.json()
|
||
} catch {
|
||
payload = null
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw buildApiResponseError(response, payload, { auth, handleUnauthorized })
|
||
}
|
||
|
||
return payload
|
||
}
|