377 lines
12 KiB
JavaScript
377 lines
12 KiB
JavaScript
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)
|
||
})
|
||
})
|