import { refresh } from './auth.js' import { session, replaceTokens, signOut, getSessionRevision } from '../stores/session.js' let refreshPromise = null let refreshRevision = -1 function requireCurrentSession(revision) { if (revision !== getSessionRevision()) throw new Error('登录会话已变更,请重新操作') } function expireSession(revision) { if (revision !== getSessionRevision()) return signOut() uni.reLaunch({ url: '/pages/login/login' }) } function errorFrom(response, fallback) { const body = response?.data || {} const error = new Error(body.message || fallback) error.code = body.code || response?.statusCode error.requestId = body.requestId || response?.header?.['x-request-id'] || response?.header?.['X-Request-Id'] error.traceId = body.traceId || response?.header?.['x-trace-id'] || response?.header?.['X-Trace-Id'] return error } function unauthorized(response) { const code = String(response?.data?.code || response?.statusCode || '') return response?.statusCode === 401 || code === 'A0401' } function successful(response, body) { return response?.statusCode === 200 && ['00000', '200'].includes(String(body?.code)) } async function refreshOverseasToken(revision) { requireCurrentSession(revision) if (!session.refreshToken) { expireSession(revision) throw new Error('登录状态已过期,请重新登录') } if (!refreshPromise || refreshRevision !== revision) { refreshRevision = revision const pending = refresh(session.refreshToken) .then((tokens) => { requireCurrentSession(revision); replaceTokens(tokens); return session.token }) .catch((error) => { expireSession(revision); throw error }) .finally(() => { if (refreshPromise === pending) refreshPromise = null }) refreshPromise = pending } return refreshPromise } async function tokenAfterUnauthorized(failedToken, revision) { requireCurrentSession(revision) if (session.token && session.token !== failedToken) return session.token return refreshOverseasToken(revision) } function overseasUrl(path) { const base = String(session.apiBase || '').replace(/\/$/, '') const nativeRuntime = typeof plus !== 'undefined' || (typeof location !== 'undefined' && location.protocol === 'file:') if (nativeRuntime && !/^https?:\/\//i.test(base)) { const error = new Error('海外服务地址未配置:App 必须设置绝对 HTTP(S) 地址 VITE_API_BASE_URL') error.code = 'PDA_API_BASE_REQUIRED' throw error } const value = `/${String(path || '').replace(/^\/+/, '')}` const normalized = /\/(?:api|gateway-api)$/.test(base) ? value.replace(/^\/api(?=\/)/, '') : value return `${base}${normalized}` } function rawRequest(method, path, data, token) { return new Promise((resolve, reject) => { uni.request({ url: overseasUrl(path), method, data, header: { Accept: 'application/json', Authorization: `Bearer ${token}` }, success: resolve, fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外服务网络连接失败'), { network: true })) }) }) } export async function overseasRequest(method, path, data, retry = true) { const revision = getSessionRevision() console.info('[Overseas PDA API] request', { method, path }) const attemptedToken = session.token let response = await rawRequest(method, path, data, attemptedToken) requireCurrentSession(revision) if (retry && unauthorized(response)) { const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision) response = await rawRequest(method, path, data, replacementToken) } requireCurrentSession(revision) if (unauthorized(response)) expireSession(revision) const body = response?.data || {} console.info('[Overseas PDA API] response', { method, path, statusCode: response?.statusCode, code: body?.code, message: body?.message, requestId: body?.requestId || response?.header?.['x-request-id'] || response?.header?.['X-Request-Id'], traceId: body?.traceId || response?.header?.['x-trace-id'] || response?.header?.['X-Trace-Id'] }) if (!successful(response, body)) throw errorFrom(response, '海外作业请求失败') return body.data } function rawUpload(path, filePath, name, token) { const browserFile = typeof Blob !== 'undefined' && filePath instanceof Blob if (!browserFile && !String(filePath || '').trim()) { const error = new Error('未获取到可上传的照片路径,请重新拍摄') error.code = 'PDA_UPLOAD_FILE_PATH_REQUIRED' return Promise.reject(error) } let url try { url = overseasUrl(path) } catch (error) { return Promise.reject(error) } const h5Runtime = typeof window !== 'undefined' && typeof plus === 'undefined' if (h5Runtime && browserFile) return new Promise((resolve, reject) => { uni.uploadFile({ url, files: [{ name, file: filePath }], header: { Authorization: `Bearer ${token}` }, success: resolve, fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true, uploadUrl: url })) }) }) if (h5Runtime && /^blob:/i.test(filePath)) { const selectedFile = fetch(filePath).then((source) => { if (!source.ok) throw new Error('无法读取已选择的照片') return source.blob() }) return selectedFile.then(async (blob) => { const form = new FormData() const suffix = String(blob.type || '').split('/')[1] || 'jpg' form.append(name, blob, blob.name || `pda-photo.${suffix}`) if (typeof XMLHttpRequest !== 'undefined') return new Promise((resolve, reject) => { const request = new XMLHttpRequest() request.open('POST', url) request.setRequestHeader('Authorization', `Bearer ${token}`) request.timeout = 60000 request.onload = () => resolve({ statusCode: request.status, data: request.responseText, header: {} }) request.onerror = () => reject(new Error('海外作业文件上传失败')) request.ontimeout = () => reject(new Error('照片上传超时,请重试')) request.onabort = () => reject(new Error('照片上传已中断,请重试')) request.send(form) }) const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 60000) try { const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form, signal: controller.signal }) return { statusCode: response.status, data: await response.text(), header: Object.fromEntries(response.headers.entries()) } } catch (cause) { if (controller.signal.aborted) throw new Error('照片上传超时,请重试') throw cause } finally { clearTimeout(timeout) } }) .catch((cause) => { throw Object.assign(new Error(cause.message || '海外作业文件上传失败'), { network: true, uploadUrl: url }) }) } return new Promise((resolve, reject) => { uni.uploadFile({ url, filePath, name, header: { Authorization: `Bearer ${token}` }, success: resolve, fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true, uploadUrl: url })) }) }) } function rawDownload(path, token) { return new Promise((resolve, reject) => { uni.downloadFile({ url: overseasUrl(path), header: { Authorization: `Bearer ${token}` }, success: resolve, fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '签收照片下载失败'), { network: true })) }) }) } export async function overseasUpload(path, filePath, name = 'file', retry = true) { const revision = getSessionRevision() const attemptedToken = session.token let response = await rawUpload(path, filePath, name, attemptedToken) requireCurrentSession(revision) let body = null try { body = JSON.parse(response.data || '{}') } catch (_) { /* handled below */ } if (retry && unauthorized({ ...response, data: body })) { const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision) response = await rawUpload(path, filePath, name, replacementToken) try { body = JSON.parse(response.data || '{}') } catch (_) { body = null } } requireCurrentSession(revision) if (unauthorized({ ...response, data: body })) expireSession(revision) if (!successful(response, body)) throw errorFrom({ ...response, data: body }, '海外作业文件上传失败') return body.data } export async function overseasDownload(path, options = {}) { const revision = getSessionRevision() const { retry = true, expectedContentTypePrefix = '' } = options const attemptedToken = session.token let response = await rawDownload(path, attemptedToken) requireCurrentSession(revision) if (retry && unauthorized(response)) { const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision) response = await rawDownload(path, replacementToken) } requireCurrentSession(revision) if (unauthorized(response)) expireSession(revision) if (response?.statusCode !== 200 || !response?.tempFilePath) { throw errorFrom({ ...response, data: {} }, '签收照片下载失败') } const contentType = String(response?.header?.['content-type'] || response?.header?.['Content-Type'] || '').toLowerCase() // App-Plus downloadFile may omit response headers even when the image body was downloaded successfully. // Reject an explicit non-image response, but allow a valid temp file when Content-Type is unavailable. if (expectedContentTypePrefix && contentType && !contentType.startsWith(expectedContentTypePrefix.toLowerCase())) { throw errorFrom({ ...response, data: {} }, '照片服务返回了无效内容,请稍后重试') } return response.tempFilePath } export function resetOverseasRequestStateForTest() { refreshPromise = null }