Files
X-Financial/web/src/services/api.js
caoxiaozhu 57957d11a0 feat: 重构报销单AI预审流程并添加平台风险规则引擎
- 将AI验审改为AI预审,高风险不再拦截而是随单流转给审批人复核
- 新增平台风险规则评估引擎,支持事由过短、票据异常、重复发票等多种评估器
- 用户上下文增加部门信息(department_name),认证流程同步关联组织架构
- 规则scenario_json改为中文标签(差旅/费用科目),统一场景分类
- 新增orchestrator审核流程测试用例
- 前端更新审计视图、差旅报销等相关页面
2026-05-20 09:36:01 +08:00

314 lines
7.4 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.
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 department = String(payload?.department || payload?.departmentName || '').trim()
const safeUsername = pickSafeHeaderValue(username)
const safeName = pickSafeHeaderValue(name)
const safeDepartment = pickSafeHeaderValue(department)
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
}
if (safeDepartment) {
headers['x-auth-department'] = safeDepartment
}
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
}