2026-07-13 14:45:36 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildBearerHeaders,
|
|
|
|
|
|
notifyAuthSessionExpired,
|
|
|
|
|
|
readAuthAccessToken
|
|
|
|
|
|
} from '../utils/authSessionStorage.js'
|
2026-06-01 17:07:14 +08:00
|
|
|
|
|
2026-05-14 02:58:35 +00:00
|
|
|
|
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 ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 14:42:04 +08:00
|
|
|
|
function normalizeApiBaseUrl(value) {
|
2026-05-09 05:59:46 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 02:25:15 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 02:58:35 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 14:45:36 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 02:25:15 +00:00
|
|
|
|
export async function apiRequest(path, options = {}) {
|
|
|
|
|
|
const {
|
2026-07-13 14:45:36 +08:00
|
|
|
|
auth = true,
|
|
|
|
|
|
handleUnauthorized = true,
|
2026-05-14 02:25:15 +00:00
|
|
|
|
contentType = 'application/json',
|
|
|
|
|
|
responseType = 'json',
|
2026-05-14 02:58:35 +00:00
|
|
|
|
headers: customHeaders,
|
2026-05-17 08:38:41 +00:00
|
|
|
|
timeoutMs = 0,
|
|
|
|
|
|
timeoutMessage = '',
|
|
|
|
|
|
...fetchOptions
|
|
|
|
|
|
} = options
|
|
|
|
|
|
|
2026-07-13 14:45:36 +08:00
|
|
|
|
const headers = sanitizeHeaders(customHeaders || {})
|
|
|
|
|
|
if (auth) {
|
|
|
|
|
|
Object.keys(headers).forEach((key) => {
|
|
|
|
|
|
if (key.toLowerCase() === 'authorization') {
|
|
|
|
|
|
delete headers[key]
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
Object.assign(headers, buildBearerHeaders())
|
|
|
|
|
|
}
|
2026-05-14 02:58:35 +00:00
|
|
|
|
|
|
|
|
|
|
if (contentType !== null && typeof headers['Content-Type'] === 'undefined') {
|
|
|
|
|
|
headers['Content-Type'] = contentType
|
|
|
|
|
|
}
|
2026-05-17 08:38:41 +00:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 02:58:35 +00:00
|
|
|
|
try {
|
2026-05-17 08:38:41 +00:00
|
|
|
|
response = await fetch(buildUrl(path), requestOptions)
|
2026-05-14 02:58:35 +00:00
|
|
|
|
} catch (error) {
|
2026-05-17 08:38:41 +00:00
|
|
|
|
if (timeoutId) {
|
|
|
|
|
|
globalThis.clearTimeout(timeoutId)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (error?.name === 'AbortError') {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const timeoutError = new Error(String(timeoutMessage || '').trim() || '接口请求超时,请稍后重试。')
|
|
|
|
|
|
timeoutError.code = 'REQUEST_TIMEOUT'
|
|
|
|
|
|
throw timeoutError
|
2026-05-17 08:38:41 +00:00
|
|
|
|
}
|
2026-05-14 02:58:35 +00:00
|
|
|
|
if (String(error?.message || '').includes('ByteString')) {
|
|
|
|
|
|
throw new Error('当前登录用户信息包含浏览器不支持的请求头字符,请重新登录后重试。')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。')
|
|
|
|
|
|
}
|
2026-05-17 08:38:41 +00:00
|
|
|
|
|
|
|
|
|
|
if (timeoutId) {
|
|
|
|
|
|
globalThis.clearTimeout(timeoutId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (responseType === 'blob') {
|
2026-05-09 05:59:46 +00:00
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
let payload = null
|
|
|
|
|
|
|
2026-05-14 02:25:15 +00:00
|
|
|
|
try {
|
|
|
|
|
|
payload = await response.json()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
payload = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 14:45:36 +08:00
|
|
|
|
throw buildApiResponseError(response, payload, { auth, handleUnauthorized })
|
2026-05-14 02:25:15 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return response.blob()
|
|
|
|
|
|
}
|
2026-05-09 05:59:46 +00:00
|
|
|
|
|
|
|
|
|
|
let payload = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
payload = await response.json()
|
2026-05-14 02:25:15 +00:00
|
|
|
|
} catch {
|
|
|
|
|
|
payload = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
2026-07-13 14:45:36 +08:00
|
|
|
|
throw buildApiResponseError(response, payload, { auth, handleUnauthorized })
|
2026-05-14 02:25:15 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return payload
|
|
|
|
|
|
}
|