import { ref } from 'vue' import { appLog, flushLogs } from '@/utils/appLog.js' // 公网统一入口:POST http:///public // 这里留一个可配置的占位符,实际项目中建议从配置文件或环境变量读取 // const baseURL = 'http://padapi.queuingsystem.cn/public' const baseURL = 'https://api-dsb.dingtax.cn/dsb/api/tax-appoint/dx/third/request/public' // 签名计算也走统一入口,通过约定的签名 tag/path 转发到内部 /api/auth const SIGN_TAG = 'pad.auth' const SIGN_PATH = '/sign' const REFRESH_TAG = 'pad.auth' const REFRESH_PATH = '/refresh' // 全局 loading 状态,可在页面上直接使用 export const loading = ref(false) // 生成 traceId:时间戳 + 随机数(满足文档“可追踪”的要求即可) const genTraceId = () => { const ts = new Date() .toISOString() .replace(/[-:TZ.]/g, '') const rand = Math.floor(Math.random() * 10000) .toString() .padStart(4, '0') return `${ts}${rand}` } // 生成随机 nonce,用于签名防重放 const genNonce = () => { return `n-${Math.random().toString(36).slice(2, 10)}` } // 深度排序对象 key,保证签名参数序列化一致 const sortKeysDeep = (value) => { if (Array.isArray(value)) { return value.map((item) => sortKeysDeep(item)) } if (value && typeof value === 'object') { const sorted = {} Object.keys(value) .sort() .forEach((key) => { sorted[key] = sortKeysDeep(value[key]) }) return sorted } return value } const clonePayloadPart = (value) => { if (value === undefined || value === null) { return {} } try { return JSON.parse(JSON.stringify(value)) } catch (error) { console.log('[request][clone-payload-error]', error, value) return {} } } const isBizSuccessCode = (code) => code === 200 || code === '200' const getBizCode = (err) => { if (!err) return null if (err.code !== undefined && err.code !== null && err.code !== '') { return Number(err.code) } if (err.data && err.data.code !== undefined && err.data.code !== null) { return Number(err.data.code) } return null } const isUnauthorizedError = (err) => { if (!err) return false if (Number(err.statusCode) === 401) return true return getBizCode(err) === 401 } const getErrorMessage = (err, fallback = '请求失败') => { if (!err) return fallback return ( err.msg || err.message || err.error || (err.data && (err.data.msg || err.data.message || err.data.error)) || fallback ) } let refreshPromise = null let sessionRestorePromise = null let needSessionRestore = false let sessionGateResolve = null let sessionGatePromise = Promise.resolve() const closeSessionGate = () => { if (!sessionGateResolve) { sessionGatePromise = new Promise((resolve) => { sessionGateResolve = resolve }) } } const openSessionGate = () => { if (sessionGateResolve) { sessionGateResolve() sessionGateResolve = null } sessionGatePromise = Promise.resolve() } /** 后台恢复续会话完成前,业务请求在此等待,避免与刷新 token 并发导致误登出 */ export const waitSessionReady = () => sessionGatePromise const isRefreshAuthFailure = (err) => { if (!err) return false if (isUnauthorizedError(err)) return true const msg = getErrorMessage(err, '') if (/refresh token 不存在|access token 为空|无效.*token|token.*无效|未授权/i.test(msg)) { return true } return false } const isLoginPage = () => { try { const pages = getCurrentPages() if (!pages || !pages.length) return false const route = String(pages[pages.length - 1].route || '') return route.indexOf('login') !== -1 } catch (error) { return false } } const clearAuthAndGoLogin = () => { appLog('warn', '[auth] clearAuthAndGoLogin: 登录态失效,准备跳转登录页') flushLogs() uni.removeStorageSync('token') uni.removeStorageSync('refresh_token') uni.removeStorageSync('refreshToken') uni.removeStorageSync('userInfo') if (isLoginPage()) { appLog('info', '[auth] 已在登录页,跳过 reLaunch') return } uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' }) setTimeout(() => { uni.reLaunch({ url: '/pages/login/login' }) }, 500) } const isNetworkError = (err) => { if (!err) return true if (err.errMsg && /fail|timeout/i.test(String(err.errMsg))) return true if (err.message && /fail|timeout|network/i.test(String(err.message))) return true return false } /** 标记需要在下次前台时续会话(锁屏/切后台) */ export const markSessionNeedsRestore = () => { needSessionRestore = true closeSessionGate() appLog('info', '[session] markSessionNeedsRestore: 已标记,等待回到前台续会话') flushLogs() } /** * 解锁或回到前台时续会话: * 1. 先用本地 access_token 校验 /profile(短时锁屏通常仍有效) * 2. 仅 401 时再刷新 token * 3. 刷新/校验鉴权失败才登出;签名或网络问题保留登录态 */ export const restoreSession = async () => { if (sessionRestorePromise) { appLog('info', '[session] restoreSession: 已有续会话进行中,复用') return sessionRestorePromise } sessionRestorePromise = (async () => { try { const token = uni.getStorageSync('token') const refreshToken = uni.getStorageSync('refresh_token') || uni.getStorageSync('refreshToken') appLog( 'info', '[session] restoreSession: 开始', `hasToken=${Boolean(token)}`, `hasRefreshToken=${Boolean(refreshToken)}`, `onLoginPage=${isLoginPage()}` ) if (!token && !refreshToken) { appLog('warn', '[session] restoreSession: 本地无登录态,跳过') return false } const tryProfile = async () => { appLog('info', '[session] restoreSession: 校验 profile') await request({ tag: 'pad.auth', path: '/profile', method: 'GET', loading: false, quiet: true, skipSessionGate: true, }) } try { if (token) { await tryProfile() appLog('info', '[session] restoreSession: 现有 token 仍有效') flushLogs() if (isLoginPage()) { appLog('info', '[session] restoreSession: 当前在登录页,跳转首页') uni.reLaunch({ url: '/pages/index/index', }) } return true } } catch (profileErr) { if (!isUnauthorizedError(profileErr)) { if (isNetworkError(profileErr)) { appLog('warn', '[session] restoreSession: profile 网络异常,保留本地登录态') return false } appLog('warn', '[session] restoreSession: profile 非鉴权失败,保留本地登录态', profileErr) return false } appLog('warn', '[session] restoreSession: profile 401,尝试刷新 token') } if (!refreshToken) { appLog('error', '[session] restoreSession: 无 refresh_token,无法续会话') clearAuthAndGoLogin() return false } appLog('info', '[session] restoreSession: 主动刷新 token') await refreshAccessToken() await tryProfile() appLog('info', '[session] restoreSession: 续会话成功') flushLogs() if (isLoginPage()) { appLog('info', '[session] restoreSession: 当前在登录页,跳转首页') uni.reLaunch({ url: '/pages/index/index', }) } return true } catch (error) { appLog('error', '[session] restoreSession: 续会话失败', error) const msg = getErrorMessage(error, '') if (/signature is blank/i.test(msg)) { appLog('warn', '[session] restoreSession: 刷新签名失败,保留本地登录态') return false } if (isUnauthorizedError(error) || isRefreshAuthFailure(error)) { appLog('warn', '[session] restoreSession: 鉴权失败,清理登录态') clearAuthAndGoLogin() return false } if (isNetworkError(error)) { appLog('warn', '[session] restoreSession: 网络异常,保留本地登录态') return false } appLog('warn', '[session] restoreSession: 未知异常,保留本地登录态', error) return false } finally { openSessionGate() sessionRestorePromise = null } })() return sessionRestorePromise } /** 仅在从后台恢复时续会话,避免首次冷启动重复打断登录页 */ export const restoreSessionIfNeeded = async () => { if (!needSessionRestore) { appLog('info', '[session] restoreSessionIfNeeded: 无需续会话') openSessionGate() return true } appLog('info', '[session] restoreSessionIfNeeded: 触发续会话') needSessionRestore = false return restoreSession() } // 组装接口要求的公共请求头 const buildHeaders = (withToken = true) => { const headers = { 'Content-Type': 'application/json' } // 如有额外网关签名、appId 等,统一在这里追加 // headers['X-App-Id'] = 'xxx' // headers['X-Sign'] = 'xxx' if (withToken) { const token = uni.getStorageSync('token') if (token) { headers.Authorization = `Bearer ${token}` } } return headers } // 统一处理 HTTP 状态码和业务状态码(文档规范) const handleResponse = (res, meta = {}) => { const { statusCode, data } = res const apiLabel = `${meta.tag || ''}${meta.path || ''}` || 'unknown' const methodLabel = meta.method || '' const traceLabel = meta.traceId || '' // 1. HTTP 层异常 if (statusCode !== 200) { let msg = '网络请求异常' if (statusCode === 400) msg = '请求参数错误' if (statusCode === 401) msg = '未授权访问' if (statusCode === 403) msg = '权限不足' if (statusCode === 404) msg = '资源不存在' if (statusCode >= 500) msg = '服务器内部错误' appLog( statusCode === 401 ? 'warn' : 'error', '[request][http-error]', `${methodLabel} ${apiLabel}`, `http=${statusCode}`, msg, data && data.msg, data && data.code, traceLabel ? `traceId=${traceLabel}` : '' ) // 401 交给上层静默刷新,避免先弹出错误打断体验 if (statusCode !== 401 && !meta.quiet) { uni.showToast({ title: `${msg} (${statusCode})`, icon: 'none' }) } console.log('[request][http-error]', res) throw Object.assign({}, res, { code: statusCode === 401 ? 401 : (data && data.code) }) } // 2. 业务层:文档约定 code=200 成功,其它为错误 if (data && isBizSuccessCode(data.code)) { // 返回真正的业务数据;data 为 null 时退回完整响应,便于从 msg 等字段解析 if (data.data !== undefined && data.data !== null) { return data.data } return data } const bizCode = data && data.code const bizMsg = (data && data.msg) || '请求失败' appLog( Number(bizCode) === 401 ? 'warn' : 'error', '[request][biz-error]', `${methodLabel} ${apiLabel}`, `code=${bizCode}`, bizMsg, traceLabel ? `traceId=${traceLabel}` : '' ) if (Number(bizCode) !== 401 && !meta.quiet) { uni.showToast({ title: `${bizMsg}${bizCode ? ` (${bizCode})` : ''}`, icon: 'none' }) } console.log('[request][biz-error]', data) throw Object.assign({}, data || {}, { code: bizCode, statusCode }) } /** * 统一 PAD 公网入口请求封装 * * 签名流程: * 1. 前端先准备业务参数(不含 signature) * 2. 调用签名服务(同样走 /public),传入 { tag, path, query, body, timestamp, nonce } * 3. 后端用 secret key 计算 HMAC-SHA256,返回 signature * 4. 前端携带 signature/timestamp/nonce 调用真正网关入口 /public * * @param {Object} options * - tag: 路由标签(必填),如 'pad.auth' * - path: map.path 子路径(必填),如 '/login' * - method: 业务含义上的 method(必填),如 'GET' / 'POST',会写入 map.head.method * - query: 对应 map.query 对象 * - body: 对应 map.body 对象 * - traceId: 可选,自定义链路号;不传则自动生成 * - withToken: 是否携带 token(登录等接口传 false) * - skipSign: 是否跳过签名(登录 /login 时传 true,不调 /api/sign、不校验签名) * - loading: 是否展示 loading * - loadingText: loading 文案 */ const request = (options) => { return runRequest(options) } const runRequest = async (options) => { const { tag, path, method = 'GET', query = {}, body = {}, traceId, withToken = true, skipSign = false, skipSessionGate = false, quiet = false, loading: showLoading = true, loadingText = '加载中...', _retry401 = false, _skip401Retry = false } = options const isRefreshRequest = tag === REFRESH_TAG && path === REFRESH_PATH if (!skipSign && !isRefreshRequest && !skipSessionGate) { await waitSessionReady() } if (!tag) { throw new Error('request 需要传入 tag') } if (!path) { throw new Error('request 需要传入 path') } const finalTraceId = traceId || genTraceId() const normalizedMethod = String(method || 'GET').toUpperCase() const normalizedQuery = sortKeysDeep(clonePayloadPart(query)) const normalizedBody = sortKeysDeep(clonePayloadPart(body)) // 登录等 skipSign 场景:不携带 token const useToken = skipSign ? false : withToken const token = useToken ? uni.getStorageSync('token') : '' const authHeader = token ? `Bearer ${token}` : '' const doRequest = (payload, withTokenHeader = useToken) => { const reqMeta = { tag, path, method: normalizedMethod, traceId: finalTraceId, quiet } return new Promise((resolve, reject) => { uni.request({ url: baseURL, method: 'POST', data: payload, header: buildHeaders(withTokenHeader), success: (res) => { try { const result = handleResponse(res, reqMeta) resolve(result) } catch (e) { console.log('[request][handleResponse-error]', e) reject(e) } }, fail: (err) => { appLog( 'error', '[request][network-fail]', `${normalizedMethod} ${tag}${path}`, err && err.errMsg, finalTraceId ? `traceId=${finalTraceId}` : '' ) if (!quiet) { uni.showToast({ title: '网络请求失败', icon: 'none' }) } console.log('[request][network-fail]', err) reject(err) }, complete: () => { if (showLoading) { loading.value = false uni.hideLoading() } } }) }) } // 401 自动刷新 token(并发请求共用一次刷新) const with401Retry = (executor) => { return executor().catch(async (err) => { const isRefreshRequest = tag === REFRESH_TAG && path === REFRESH_PATH if (!_retry401 && !_skip401Retry && !isRefreshRequest && isUnauthorizedError(err)) { appLog( 'warn', '[request][401]', `tag=${tag}`, `path=${path}`, '触发静默刷新并重试' ) try { await refreshAccessToken() // 刷新后重试一次(重走签名流程) appLog('info', '[request][401]', `tag=${tag}`, `path=${path}`, '刷新成功,重试原请求') return await request({ ...options, _retry401: true, loading: false }) } catch (refreshErr) { appLog('error', '[request][401]', '刷新失败', refreshErr) if (isRefreshAuthFailure(refreshErr)) { clearAuthAndGoLogin() } else { appLog('warn', '[request][401]', '刷新失败(非鉴权原因),保留登录态') } throw err } } if (_retry401 && isUnauthorizedError(err)) { appLog('error', '[request][401]', `tag=${tag}`, `path=${path}`, '重试后仍 401') clearAuthAndGoLogin() } throw err }) } if (showLoading) { loading.value = true uni.showLoading({ title: loadingText, mask: true }) } // 登录等接口:跳过 /api/sign,直接请求业务网关,head 中不含 signature/timestamp/nonce if (skipSign) { const payload = { tag, traceId: finalTraceId, map: { traceId: finalTraceId, head: { method: normalizedMethod, contentType: 'application/json', ...(authHeader ? { Authorization: authHeader } : {}) }, path, query: normalizedQuery, body: normalizedBody } } return with401Retry(() => doRequest(payload)) } // 常规流程:先走统一入口调用签名服务,再携带 signature/timestamp/nonce 调业务接口 const timestamp = Date.now() const nonce = genNonce() const tokenForSign = uni.getStorageSync('token') const signPayload = { tag: SIGN_TAG, traceId: finalTraceId, map: { traceId: finalTraceId, head: { method: 'POST', contentType: 'application/json', timestamp, nonce, // 调用签名服务时在 head 中显式传入 token ...(tokenForSign ? { Authorization: `Bearer ${tokenForSign}` } : {}) }, path: SIGN_PATH, query: {}, // 签名参数需与目标业务请求一致:{ tag, path, query, body, timestamp, nonce } body: sortKeysDeep({ tag, path, query: normalizedQuery, body: normalizedBody, timestamp, nonce }) } } if (isRefreshRequest) { appLog( 'info', '[auth][refresh-sign]', '准备调用 /sign', `baseURL=${baseURL}`, `traceId=${finalTraceId}`, `hasAccessToken=${Boolean(tokenForSign)}`, `accessTokenLen=${tokenForSign ? String(tokenForSign).length : 0}`, `timestamp=${timestamp}`, `nonce=${nonce}`, 'signBody=', { tag, path, query: normalizedQuery, bodyKeys: Object.keys(normalizedBody || {}), hasRefreshToken: Boolean(normalizedBody && normalizedBody.refreshToken), refreshTokenLen: normalizedBody && normalizedBody.refreshToken ? String(normalizedBody.refreshToken).length : 0, timestamp, nonce, } ) flushLogs() } return with401Retry(() => new Promise((resolve, reject) => { uni.request({ // 签名服务也通过公网统一入口 /public,由 SIGN_TAG + SIGN_PATH 路由到内部 /api/sign url: baseURL, method: 'POST', data: signPayload, header: buildHeaders(useToken), success: (signRes) => { try { const signData = signRes.data || {} const signOk = signRes.statusCode === 200 && isBizSuccessCode(signData.code) if (isRefreshRequest) { const dataKeys = signData.data && typeof signData.data === 'object' ? Object.keys(signData.data) : [] const rawSig = (signData.data && signData.data.signature) || signData.signature || '' appLog( 'info', '[auth][refresh-sign]', '/sign 返回', `http=${signRes.statusCode}`, `code=${signData.code}`, `msg=${signData.msg || signData.message || signData.error || ''}`, `signOk=${signOk}`, `dataKeys=${JSON.stringify(dataKeys)}`, `hasSignature=${Boolean(rawSig)}`, `signatureLen=${rawSig ? String(rawSig).length : 0}`, `signaturePrefix=${rawSig ? String(rawSig).slice(0, 12) : ''}`, 'signData=', signData ) flushLogs() } if (!signOk) { const signErr = Object.assign({}, signRes, { code: signData.code, msg: signData.error || signData.msg || signData.message || '获取签名失败', data: signData }) // 401:不弹 Toast,交给 with401Retry 静默刷新后重试 if (isUnauthorizedError(signErr)) { appLog( 'warn', '[request][sign]', `业务=${tag}${path}`, '签名接口返回 401,准备静默刷新' ) } else { appLog( 'error', '[request][sign]', `业务=${tag}${path}`, '获取签名失败', signErr.msg, signData.code ) if (!quiet) { uni.showToast({ title: getErrorMessage(signErr, '获取签名失败'), icon: 'none' }) } } console.log('[request][sign-response-error]', signRes) console.log('[request][sign-request-body]', { tag, path, method: normalizedMethod, query: normalizedQuery, body: normalizedBody, }) throw signErr } const signature = (signData.data && signData.data.signature) || signData.signature if (!signature) { appLog( 'error', '[request][sign]', `业务=${tag}${path}`, '签名结果为空', `traceId=${finalTraceId}`, 'signData=', signData ) console.log('[request][sign-empty]', signRes) throw new Error('签名结果为空') } const payload = { tag, traceId: finalTraceId, map: { traceId: finalTraceId, head: { method: normalizedMethod, contentType: 'application/json', signature, timestamp, nonce, ...(authHeader ? { Authorization: authHeader } : {}) }, path, query: normalizedQuery, body: normalizedBody } } if (isRefreshRequest) { const head = payload.map.head || {} appLog( 'info', '[auth][refresh-req]', '即将调用 /refresh', `baseURL=${baseURL}`, `traceId=${finalTraceId}`, `path=${payload.map.path}`, `hasSignature=${Boolean(head.signature)}`, `signatureLen=${head.signature ? String(head.signature).length : 0}`, `signaturePrefix=${head.signature ? String(head.signature).slice(0, 12) : ''}`, `timestamp=${head.timestamp}`, `nonce=${head.nonce}`, `hasAuthorization=${Boolean(head.Authorization)}`, `bodyHasRefreshToken=${Boolean( payload.map.body && payload.map.body.refreshToken )}`, 'headKeys=', Object.keys(head) ) flushLogs() } doRequest(payload) .then((result) => { if (isRefreshRequest) { appLog( 'info', '[auth][refresh-req]', '/refresh 业务成功', `hasAccessToken=${Boolean( result && (result.access_token || result.token) )}`, `hasRefreshToken=${Boolean( result && (result.refresh_token || result.refreshToken) )}` ) flushLogs() } resolve(result) }) .catch((err) => { if (isRefreshRequest) { appLog( 'error', '[auth][refresh-req]', '/refresh 业务失败', `http=${err && err.statusCode}`, `code=${getBizCode(err)}`, `msg=${getErrorMessage(err, '')}`, 'err=', err ) flushLogs() } reject(err) }) } catch (err) { if (showLoading) { loading.value = false uni.hideLoading() } if (!isUnauthorizedError(err)) { appLog( 'error', '[request][sign-process-error]', `业务=${tag}${path}`, getErrorMessage(err, '签名流程异常'), `traceId=${finalTraceId}` ) } console.log('[request][sign-process-error]', err) reject(err) } }, fail: (err) => { if (showLoading) { loading.value = false uni.hideLoading() } if (isRefreshRequest) { appLog( 'error', '[auth][refresh-sign]', '/sign 网络失败', err && err.errMsg, `traceId=${finalTraceId}` ) flushLogs() } appLog( 'error', '[request][sign-network-fail]', `业务=${tag}${path}`, err && err.errMsg, `traceId=${finalTraceId}` ) if (!quiet) { uni.showToast({ title: '获取签名接口失败', icon: 'none' }) } console.log('[request][sign-network-fail]', err) reject(err) } }) })) } const refreshAccessToken = () => { if (refreshPromise) { appLog('info', '[auth] refreshAccessToken: 复用进行中的刷新请求') return refreshPromise } refreshPromise = (async () => { const refreshToken = uni.getStorageSync('refresh_token') || uni.getStorageSync('refreshToken') if (!refreshToken) { appLog('error', '[auth] refreshAccessToken: refresh token 不存在') throw new Error('refresh token 不存在') } appLog( 'info', '[auth] refreshAccessToken: 开始刷新 token(签名网关)', `baseURL=${baseURL}`, `hasAccessToken=${Boolean(uni.getStorageSync('token'))}`, `refreshTokenLen=${String(refreshToken).length}` ) flushLogs() // /sign 需要 Bearer;短时后台时旧 access_token 通常仍可用来签名 // 若 access 已失效,签名会 401,由上层决定是否登出 const tokenData = await runRequest({ tag: REFRESH_TAG, path: REFRESH_PATH, method: 'POST', body: { refreshToken }, withToken: true, quiet: true, loading: false, skipSessionGate: true, _skip401Retry: true, }) const newAccessToken = tokenData?.access_token || tokenData?.token || '' const newRefreshToken = tokenData?.refresh_token || tokenData?.refreshToken || '' if (!newAccessToken) { appLog( 'error', '[auth] refreshAccessToken: 刷新成功但 access token 为空', 'tokenDataKeys=', tokenData && typeof tokenData === 'object' ? Object.keys(tokenData) : [], 'tokenData=', tokenData ) throw new Error('刷新成功但 access token 为空') } uni.setStorageSync('token', newAccessToken) if (newRefreshToken) { uni.setStorageSync('refresh_token', newRefreshToken) } appLog( 'info', '[auth] refreshAccessToken: 刷新成功', `accessTokenLen=${String(newAccessToken).length}`, `gotNewRefreshToken=${Boolean(newRefreshToken)}` ) flushLogs() return newAccessToken })().catch((err) => { appLog( 'error', '[auth] refreshAccessToken: 最终失败', `code=${getBizCode(err)}`, `msg=${getErrorMessage(err, '')}`, err ) flushLogs() throw err }).finally(() => { refreshPromise = null }) return refreshPromise } export default request