- 将AI验审改为AI预审,高风险不再拦截而是随单流转给审批人复核 - 新增平台风险规则评估引擎,支持事由过短、票据异常、重复发票等多种评估器 - 用户上下文增加部门信息(department_name),认证流程同步关联组织架构 - 规则scenario_json改为中文标签(差旅/费用科目),统一场景分类 - 新增orchestrator审核流程测试用例 - 前端更新审计视图、差旅报销等相关页面
314 lines
7.4 KiB
JavaScript
314 lines
7.4 KiB
JavaScript
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
|
||
}
|