import { runtimeConfig } from '@/utils/runtime-config.js' import { recoverToAuthRoot } from '@/utils/navigation/gateway.js' import { session } from '@/utils/session.js' import { createRequestCancelledError } from './request-controller.js' const SUCCESS_CODES = [0, 200] const REQUEST_TIMEOUT_MS = 15000 const FILE_UPLOAD_TIMEOUT_MS = 120000 export const createRequestError = (message, code, details = {}) => { const error = new Error(message) error.code = code Object.assign(error, details) return error } const isAuthenticatedSessionRejected = (error) => (error?.code === 'BUSINESS_ERROR' && Number(error.businessCode) === 401) || (error?.code === 'HTTP_ERROR' && Number(error.httpStatus) === 401) let authenticationRecoveryInFlight = false const recoverExpiredAuthenticatedSession = () => { if (authenticationRecoveryInFlight) return authenticationRecoveryInFlight = true session.clear() const finishRecovery = () => { authenticationRecoveryInFlight = false } recoverToAuthRoot().then(finishRecovery, finishRecovery) } export const unwrapResponse = (response) => { if (!response || typeof response !== 'object' || Array.isArray(response)) return response if (!Object.prototype.hasOwnProperty.call(response, 'code')) return response if (!SUCCESS_CODES.includes(Number(response.code))) { const numericCode = Number(response.code) throw createRequestError(response.msg || '请求未成功', 'BUSINESS_ERROR', { businessCode: Number.isNaN(numericCode) ? response.code : numericCode }) } return Object.prototype.hasOwnProperty.call(response, 'data') ? response.data : response } export const request = (options, { authenticated = true, strictEnvelope = false, requireEnvelopeData = false, expectedStatus = null, requestController = null } = {}) => new Promise((resolve, reject) => { if (requestController !== null && ( typeof requestController.bind !== 'function' || typeof requestController.release !== 'function' )) { reject(new TypeError('请求控制器格式无效')) return } let settled = false let requestTask = null const release = () => requestController?.release(abortRequest) const resolveOnce = (value) => { if (settled) return settled = true release() resolve(value) } const rejectOnce = (error) => { if (settled) return settled = true release() reject(error) } const abortRequest = () => { if (settled) return const task = requestTask rejectOnce(createRequestCancelledError()) task?.abort?.() } const token = session.getToken() requestTask = uni.request({ ...options, url: `${runtimeConfig.baseUrl}${options.url}`, header: { clientid: runtimeConfig.clientId, tenantId: runtimeConfig.tenantId, ...(authenticated && token ? { Authorization: `Bearer ${token}` } : {}), ...(options.header || {}) }, success: ({ data, statusCode }) => { if (expectedStatus !== null && statusCode !== expectedStatus) { const error = createRequestError( `服务只接受 HTTP ${expectedStatus} 响应,实际为 ${statusCode}`, 'HTTP_ERROR', { httpStatus: statusCode } ) if (authenticated && isAuthenticatedSessionRejected(error)) { recoverExpiredAuthenticatedSession() } rejectOnce(error) return } if (statusCode < 200 || statusCode >= 300) { const error = createRequestError( data?.msg || `请求失败(${statusCode})`, 'HTTP_ERROR', { httpStatus: statusCode } ) if (authenticated && isAuthenticatedSessionRejected(error)) { recoverExpiredAuthenticatedSession() } rejectOnce(error) return } if ( strictEnvelope && (!data || typeof data !== 'object' || Array.isArray(data) || !Object.prototype.hasOwnProperty.call(data, 'code') || !Number.isInteger(data.code) || (requireEnvelopeData && !Object.prototype.hasOwnProperty.call(data, 'data'))) ) { rejectOnce(createRequestError('服务响应格式无效', 'RESPONSE_INVALID')) return } try { resolveOnce(unwrapResponse(data)) } catch (error) { // 业务 401 表示本地会话已经失效:只撤销本地令牌和家谱上下文, // 不调用退出接口、不重试写操作,也不影响未携带会话的登录/注册请求。 if (authenticated && isAuthenticatedSessionRejected(error)) { recoverExpiredAuthenticatedSession() } rejectOnce(error) } }, fail: (error) => { const message = error?.errMsg || '网络连接失败' rejectOnce(createRequestError( message, /timeout/i.test(message) ? 'REQUEST_TIMEOUT' : 'NETWORK_ERROR' )) } }) if (!settled && requestController) requestController.bind(abortRequest) }) // 已核对的接口使用严格边界:只接受 HTTP 200 与完整 JSON envelope, // 并统一限制弱网等待时间;尚未逐页治理的旧读取仍使用通用 request。 export const requestStrict = (options, { authenticated = true, requireData = true, requestController = null } = {}) => request({ ...options, timeout: REQUEST_TIMEOUT_MS }, { authenticated, strictEnvelope: true, requireEnvelopeData: requireData, expectedStatus: 200, requestController }) export const requestAuth = (options, { requestController = null } = {}) => requestStrict(options, { authenticated: false, requestController }) // RVoid 的 OpenAPI schema 没有把 data 声明为必填。空结果接口仍要求合法的 // HTTP 200 与整数 code,但统一向页面返回 null,避免把 envelope 泄漏成业务数据。 export const requestAuthVoid = async (options, { requestController = null } = {}) => { await requestStrict(options, { authenticated: false, requireData: false, requestController }) return null } const parseStrictUploadResponse = (responseText, requireData) => { let uploadEnvelope try { uploadEnvelope = JSON.parse(responseText) } catch { throw createRequestError('文件服务响应格式无效', 'RESPONSE_INVALID') } if ( !uploadEnvelope || typeof uploadEnvelope !== 'object' || Array.isArray(uploadEnvelope) || !Object.prototype.hasOwnProperty.call(uploadEnvelope, 'code') || !Number.isInteger(uploadEnvelope.code) || (requireData && !Object.prototype.hasOwnProperty.call(uploadEnvelope, 'data')) ) { throw createRequestError('文件服务响应格式无效', 'RESPONSE_INVALID') } return unwrapResponse(uploadEnvelope) } const rejectAuthenticatedUploadStatus = (statusCode) => { const error = createRequestError( `文件分片上传失败(HTTP ${statusCode})`, 'HTTP_ERROR', { httpStatus: statusCode } ) if (isAuthenticatedSessionRejected(error)) recoverExpiredAuthenticatedSession() return error } const unwrapAuthenticatedUploadResponse = (responseText) => { try { return parseStrictUploadResponse(responseText, false) } catch (error) { if (isAuthenticatedSessionRejected(error)) recoverExpiredAuthenticatedSession() throw error } } export const requestNativeFileChunk = ({ uploadId, chunkIndex, chunkMd5, filePath }, { requestController = null } = {}) => new Promise((resolve, reject) => { if (typeof globalThis.uni?.uploadFile !== 'function') { reject(createRequestError('当前运行环境不支持文件上传', 'FILE_UPLOAD_UNAVAILABLE')) return } if (requestController !== null && ( typeof requestController.bind !== 'function' || typeof requestController.release !== 'function' )) { reject(new TypeError('文件上传控制器格式无效')) return } let settled = false let task = null let cancelled = false const release = () => requestController?.release(abort) const resolveOnce = (value) => { if (settled) return settled = true release() resolve(value) } const rejectOnce = (error) => { if (settled) return settled = true release() reject(error) } const abort = () => { if (settled) return cancelled = true task?.abort?.() rejectOnce(createRequestCancelledError()) } const token = session.getToken() try { task = globalThis.uni.uploadFile({ url: `${runtimeConfig.baseUrl}/genealogy/app/files/resumable/chunk`, filePath, name: 'file', formData: { uploadId, chunkIndex: String(chunkIndex), chunkMd5 }, header: { clientid: runtimeConfig.clientId, tenantId: runtimeConfig.tenantId, ...(token ? { Authorization: `Bearer ${token}` } : {}) }, timeout: FILE_UPLOAD_TIMEOUT_MS, success: (upload) => { if (upload.statusCode !== 200) { rejectOnce(rejectAuthenticatedUploadStatus(upload.statusCode)) return } try { unwrapAuthenticatedUploadResponse(upload.data) resolveOnce(null) } catch (error) { rejectOnce(error) } }, fail: (error) => { if (settled) return if (cancelled) { rejectOnce(createRequestCancelledError()) return } const message = typeof error?.errMsg === 'string' && error.errMsg ? error.errMsg : '文件分片上传失败' const timeout = /timeout/i.test(message) rejectOnce(createRequestError( timeout ? '文件分片上传超时' : message, timeout ? 'REQUEST_TIMEOUT' : 'UPLOAD_FAILED' )) } }) if (requestController) requestController.bind(abort) } catch (error) { rejectOnce(error) } }) export const requestBrowserFileChunk = ({ uploadId, chunkIndex, chunkMd5, file }, { requestController = null } = {}) => new Promise((resolve, reject) => { if (typeof globalThis.fetch !== 'function' || typeof globalThis.FormData !== 'function') { reject(createRequestError('当前运行环境不支持文件上传', 'FILE_UPLOAD_UNAVAILABLE')) return } if (requestController !== null && ( typeof requestController.bind !== 'function' || typeof requestController.release !== 'function' )) { reject(new TypeError('文件上传控制器格式无效')) return } let settled = false let timeoutId = null const abortController = new AbortController() const release = () => requestController?.release(abort) const clearRequestTimeout = () => { if (timeoutId === null) return clearTimeout(timeoutId) timeoutId = null } const resolveOnce = (value) => { if (settled) return settled = true clearRequestTimeout() release() resolve(value) } const rejectOnce = (error) => { if (settled) return settled = true clearRequestTimeout() release() reject(error) } const abort = () => { if (settled) return abortController.abort() rejectOnce(createRequestCancelledError()) } const token = session.getToken() const formData = new globalThis.FormData() formData.append('uploadId', uploadId) formData.append('chunkIndex', String(chunkIndex)) formData.append('chunkMd5', chunkMd5) formData.append('file', file, file.name || 'image') if (requestController) requestController.bind(abort) timeoutId = setTimeout(() => { abortController.abort() rejectOnce(createRequestError('文件分片上传超时', 'REQUEST_TIMEOUT')) }, FILE_UPLOAD_TIMEOUT_MS) globalThis.fetch(`${runtimeConfig.baseUrl}/genealogy/app/files/resumable/chunk`, { method: 'POST', headers: { clientid: runtimeConfig.clientId, tenantId: runtimeConfig.tenantId, ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: formData, signal: abortController.signal }).then(async (response) => { if (response.status !== 200) { throw rejectAuthenticatedUploadStatus(response.status) } unwrapAuthenticatedUploadResponse(await response.text()) resolveOnce(null) }).catch((error) => { if (settled) return rejectOnce(error?.name === 'AbortError' ? createRequestCancelledError() : error) }) })