const API_BASE_STORAGE_KEY = 'x-financial-api-base-url' const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user' 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 readCurrentUserHeaders() { if (typeof window === 'undefined') { return {} } const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY) if (!raw) { return {} } try { const payload = JSON.parse(raw) const username = String(payload?.username || '').trim() const name = String(payload?.name || username).trim() const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : [] const isAdmin = Boolean(payload?.isAdmin) const safeUsername = pickSafeHeaderValue(username) const safeName = pickSafeHeaderValue(name) if (!safeUsername && !safeName) { return {} } const headers = { 'x-auth-role-codes': roleCodes.join(','), 'x-auth-is-admin': String(isAdmin) } if (safeUsername) { headers['x-auth-username'] = safeUsername } if (safeName) { headers['x-auth-name'] = safeName } return headers } catch { 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 } export async function apiRequest(path, options = {}) { const { contentType = 'application/json', responseType = 'json', headers: customHeaders, timeoutMs = 0, timeoutMessage = '', ...fetchOptions } = options const headers = sanitizeHeaders({ ...readCurrentUserHeaders(), ...(customHeaders || {}) }) 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') { throw new Error(String(timeoutMessage || '').trim() || '接口请求超时,请稍后重试。') } 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 new Error(resolveErrorMessage(payload)) } return response.blob() } let payload = null try { payload = await response.json() } catch { payload = null } if (!response.ok) { throw new Error(resolveErrorMessage(payload)) } return payload }