feat: migrate app routes and business modules
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
import { runtimeConfig } from '@/utils/runtime-config.js'
|
||||
import { goRoot } 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
|
||||
|
||||
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
|
||||
|
||||
let authenticationRecoveryInFlight = false
|
||||
|
||||
const recoverExpiredAuthenticatedSession = () => {
|
||||
if (authenticationRecoveryInFlight) return
|
||||
authenticationRecoveryInFlight = true
|
||||
session.clear()
|
||||
const finishRecovery = () => {
|
||||
authenticationRecoveryInFlight = false
|
||||
}
|
||||
goRoot('A01').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) {
|
||||
rejectOnce(createRequestError(
|
||||
`服务只接受 HTTP ${expectedStatus} 响应,实际为 ${statusCode}`,
|
||||
'HTTP_ERROR',
|
||||
{ httpStatus: statusCode }
|
||||
))
|
||||
return
|
||||
}
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
rejectOnce(createRequestError(
|
||||
data?.msg || `请求失败(${statusCode})`,
|
||||
'HTTP_ERROR',
|
||||
{ httpStatus: statusCode }
|
||||
))
|
||||
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)
|
||||
}
|
||||
|
||||
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: REQUEST_TIMEOUT_MS,
|
||||
success: (upload) => {
|
||||
if (upload.statusCode !== 200) {
|
||||
rejectOnce(createRequestError(`文件分片上传失败(HTTP ${upload.statusCode})`, 'HTTP_ERROR', { httpStatus: upload.statusCode }))
|
||||
return
|
||||
}
|
||||
try {
|
||||
parseStrictUploadResponse(upload.data, false)
|
||||
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'))
|
||||
}, REQUEST_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 createRequestError(`文件分片上传失败(HTTP ${response.status})`, 'HTTP_ERROR', { httpStatus: response.status })
|
||||
}
|
||||
parseStrictUploadResponse(await response.text(), false)
|
||||
resolveOnce(null)
|
||||
}).catch((error) => {
|
||||
if (settled) return
|
||||
rejectOnce(error?.name === 'AbortError' ? createRequestCancelledError() : error)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user