|
|
import { ref } from 'vue'
|
|
|
import { appLog, flushLogs } from '@/utils/appLog.js'
|
|
|
|
|
|
// 公网统一入口:POST http://<gateway-host>/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 |