Files
X-Financial/web/src/services/api.js
2026-07-13 14:45:36 +08:00

290 lines
6.9 KiB
JavaScript
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 {
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
}