Files

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