372 lines
12 KiB
JavaScript
372 lines
12 KiB
JavaScript
import { TOKEN_KEY, REFRESH_TOKEN_KEY, USER_INFO_KEY, resolveApiBase } from '@/config/api'
|
||
|
||
let clearingAuth = false
|
||
// TODO(config-hardcoded): 以下加密参数为临时写死,后续应迁移至构建期注入或安全配置下发。
|
||
// 写死配置:不从环境变量读取,避免小程序端注入差异导致行为不一致。
|
||
// 移动端(H5 + 微信小程序)统一走明文 JSON,便于联调排查入参;Web 管理端仍可单独开加密。
|
||
const API_ENCRYPT_ENABLED = false
|
||
const API_ENCRYPT_FAIL_OPEN = false
|
||
const API_ENCRYPT_FIXED_SECRET = 'shanxia-web-api-crypto-fixed-secret'
|
||
// TODO(config-hardcoded): 排除路径清单后续改为集中配置,避免多端重复维护。
|
||
const API_ENCRYPT_EXCLUDE_PATHS = String(
|
||
'/web/auth/login-rsa-public-key,/web/auth/login,/web/auth/refresh-token,/mini/auth/wechat-login,/mini/auth/web-qr-scanned,/mini/auth/web-qr-confirm'
|
||
)
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
|
||
function appendTimestampQuery(url) {
|
||
const base = String(url || '')
|
||
const ts = Date.now()
|
||
if (!base) return `${base}?_t=${ts}`
|
||
// 已有 _t 时覆盖为最新值,避免 query 反复堆叠
|
||
if (/[?&]_t=/.test(base)) {
|
||
return base.replace(/([?&]_t=)\d+/, `$1${ts}`)
|
||
}
|
||
return `${base}${base.includes('?') ? '&' : '?'}_t=${ts}`
|
||
}
|
||
|
||
function createUnauthorizedError(rawUrl = '') {
|
||
const loginApiPattern = /\/web\/auth\/login(?:$|\?)/
|
||
const message = loginApiPattern.test(String(rawUrl))
|
||
? '用户名或密码错误,请重新输入'
|
||
: '登录状态已失效,请重新登录'
|
||
const error = new Error(message)
|
||
error.name = 'UnauthorizedError'
|
||
return error
|
||
}
|
||
|
||
async function onHttpUnauthorized() {
|
||
if (clearingAuth) return
|
||
clearingAuth = true
|
||
// 先同步清理本地登录态,避免 store 动态导入失败时仍被识别为已登录
|
||
uni.removeStorageSync(TOKEN_KEY)
|
||
uni.removeStorageSync(REFRESH_TOKEN_KEY)
|
||
uni.removeStorageSync(USER_INFO_KEY)
|
||
try {
|
||
const { useUserStore } = await import('@/stores/user')
|
||
useUserStore().clearLoginInfo()
|
||
} catch (error) {
|
||
// ignore
|
||
}
|
||
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
|
||
const currentRoute = pages.length ? pages[pages.length - 1].route : ''
|
||
if (currentRoute === 'pages/login/login') {
|
||
clearingAuth = false
|
||
return
|
||
}
|
||
uni.showToast({ title: '登录已失效,请重新登录', icon: 'none' })
|
||
uni.reLaunch({
|
||
url: '/pages/login/login',
|
||
fail: () => {
|
||
// 某些端在请求回调中 reLaunch 可能失败,使用 redirectTo 兜底
|
||
uni.redirectTo({ url: '/pages/login/login' })
|
||
},
|
||
complete: () => {
|
||
setTimeout(() => {
|
||
clearingAuth = false
|
||
}, 800)
|
||
}
|
||
})
|
||
}
|
||
|
||
function isBizUnauthorized(data) {
|
||
if (!data || typeof data !== 'object') return false
|
||
return data.code === 401
|
||
}
|
||
|
||
function randomNonce() {
|
||
const bytes = new Uint8Array(12)
|
||
const c = globalThis.crypto
|
||
if (!c || typeof c.getRandomValues !== 'function') {
|
||
throw new Error('当前环境不支持随机数生成')
|
||
}
|
||
c.getRandomValues(bytes)
|
||
return Array.from(bytes)
|
||
.map((b) => b.toString(16).padStart(2, '0'))
|
||
.join('')
|
||
}
|
||
|
||
function hexToBytes(hex) {
|
||
const out = new Uint8Array(hex.length / 2)
|
||
for (let i = 0; i < out.length; i += 1) {
|
||
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16)
|
||
}
|
||
return out
|
||
}
|
||
|
||
function bytesToHex(bytes) {
|
||
return Array.from(bytes)
|
||
.map((b) => b.toString(16).padStart(2, '0'))
|
||
.join('')
|
||
}
|
||
|
||
function bytesToBase64(bytes) {
|
||
let binary = ''
|
||
bytes.forEach((b) => {
|
||
binary += String.fromCharCode(b)
|
||
})
|
||
return btoa(binary)
|
||
}
|
||
|
||
function base64ToBytes(base64) {
|
||
const binary = atob(base64)
|
||
const out = new Uint8Array(binary.length)
|
||
for (let i = 0; i < binary.length; i += 1) {
|
||
out[i] = binary.charCodeAt(i)
|
||
}
|
||
return out
|
||
}
|
||
|
||
function toBase64Url(bytes) {
|
||
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||
}
|
||
|
||
function fromBase64Url(base64url) {
|
||
const base64 = String(base64url || '').replace(/-/g, '+').replace(/_/g, '/')
|
||
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
|
||
return base64ToBytes(base64 + padding)
|
||
}
|
||
|
||
async function sha256Hex(text) {
|
||
const c = globalThis.crypto
|
||
if (!c || !c.subtle || typeof c.subtle.digest !== 'function') {
|
||
throw new Error('当前环境不支持摘要算法')
|
||
}
|
||
const data = new TextEncoder().encode(String(text || ''))
|
||
const digest = await c.subtle.digest('SHA-256', data)
|
||
return bytesToHex(new Uint8Array(digest))
|
||
}
|
||
|
||
async function deriveKey(fixed, userId, ts, nonce) {
|
||
return sha256Hex(`${fixed}:${userId}:${ts}:${nonce}`)
|
||
}
|
||
|
||
async function encryptJson(payload, keyHex) {
|
||
const c = globalThis.crypto
|
||
if (!c || !c.subtle || typeof c.subtle.importKey !== 'function') {
|
||
throw new Error('当前环境不支持加密算法')
|
||
}
|
||
const iv = c.getRandomValues(new Uint8Array(12))
|
||
const key = await c.subtle.importKey('raw', hexToBytes(keyHex), 'AES-GCM', false, ['encrypt'])
|
||
const plain = new TextEncoder().encode(JSON.stringify(payload || {}))
|
||
const encrypted = await c.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain)
|
||
const merged = new Uint8Array(iv.length + encrypted.byteLength)
|
||
merged.set(iv, 0)
|
||
merged.set(new Uint8Array(encrypted), iv.length)
|
||
return toBase64Url(merged)
|
||
}
|
||
|
||
async function decryptJson(edata, keyHex) {
|
||
const c = globalThis.crypto
|
||
if (!c || !c.subtle || typeof c.subtle.decrypt !== 'function') {
|
||
throw new Error('当前环境不支持解密算法')
|
||
}
|
||
const raw = fromBase64Url(edata)
|
||
if (raw.length < 13) {
|
||
throw new Error('响应密文非法')
|
||
}
|
||
const iv = raw.slice(0, 12)
|
||
const cipher = raw.slice(12)
|
||
const key = await c.subtle.importKey('raw', hexToBytes(keyHex), 'AES-GCM', false, ['decrypt'])
|
||
const decrypted = await c.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher)
|
||
const text = new TextDecoder().decode(new Uint8Array(decrypted))
|
||
return JSON.parse(text)
|
||
}
|
||
|
||
function parseUserIdFromToken(token) {
|
||
const raw = String(token || '')
|
||
const parts = raw.split('.')
|
||
if (parts.length < 2) return ''
|
||
try {
|
||
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||
const pad = '='.repeat((4 - (b64.length % 4)) % 4)
|
||
const json = atob(b64 + pad)
|
||
const obj = JSON.parse(json)
|
||
return String(obj?.sub || '')
|
||
} catch (error) {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
function normalizeRequestData(data) {
|
||
if (!data) return {}
|
||
if (typeof data === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(data)
|
||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||
} catch (error) {
|
||
return {}
|
||
}
|
||
}
|
||
return typeof data === 'object' ? data : {}
|
||
}
|
||
|
||
function isNativeFormData(data) {
|
||
// 微信小程序等运行时可能不存在全局 FormData,直接 instanceof 会抛错
|
||
return typeof FormData !== 'undefined' && data instanceof FormData
|
||
}
|
||
|
||
function shouldEncryptRequest(rawUrl, userId, data) {
|
||
if (!API_ENCRYPT_ENABLED || !API_ENCRYPT_FIXED_SECRET) return false
|
||
if (!rawUrl || !(/\/web\//.test(String(rawUrl)) || /\/mini\//.test(String(rawUrl)))) return false
|
||
if (!userId) return false
|
||
if (isNativeFormData(data)) return false
|
||
return !API_ENCRYPT_EXCLUDE_PATHS.some((prefix) => String(rawUrl).startsWith(prefix))
|
||
}
|
||
|
||
function getEncryptSkipReason(rawUrl, userId, data) {
|
||
if (!API_ENCRYPT_ENABLED) return 'encrypt-disabled'
|
||
if (!API_ENCRYPT_FIXED_SECRET) return 'missing-fixed-secret'
|
||
if (!rawUrl || !(/\/web\//.test(String(rawUrl)) || /\/mini\//.test(String(rawUrl)))) return 'non-api-path'
|
||
if (!userId) return 'missing-user-id'
|
||
if (isNativeFormData(data)) return 'form-data'
|
||
if (API_ENCRYPT_EXCLUDE_PATHS.some((prefix) => String(rawUrl).startsWith(prefix))) return 'exclude-path'
|
||
return ''
|
||
}
|
||
|
||
export function request(options) {
|
||
if (clearingAuth) {
|
||
return Promise.reject(createUnauthorizedError(options?.url))
|
||
}
|
||
const rawToken = uni.getStorageSync(TOKEN_KEY)
|
||
const token = typeof rawToken === 'string' ? rawToken.trim() : ''
|
||
const rawUrl = options.url || ''
|
||
const method = String(options.method || 'GET').toUpperCase()
|
||
const userIdFromStorage = (() => {
|
||
const rawUserInfo = uni.getStorageSync(USER_INFO_KEY)
|
||
if (rawUserInfo && typeof rawUserInfo === 'object') {
|
||
return String(rawUserInfo.id || rawUserInfo.userId || '')
|
||
}
|
||
if (typeof rawUserInfo === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(rawUserInfo)
|
||
return String(parsed?.id || parsed?.userId || '')
|
||
} catch (error) {
|
||
return ''
|
||
}
|
||
}
|
||
return ''
|
||
})()
|
||
const userId = userIdFromStorage || parseUserIdFromToken(token)
|
||
const normalizedUrl = rawUrl.startsWith('http')
|
||
? rawUrl
|
||
: `${resolveApiBase()}${rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`}`
|
||
const url = appendTimestampQuery(normalizedUrl)
|
||
const shouldEncrypt = shouldEncryptRequest(rawUrl, userId, options.data)
|
||
const skipReason = shouldEncrypt ? '' : getEncryptSkipReason(rawUrl, userId, options.data)
|
||
|
||
return new Promise(async (resolve, reject) => {
|
||
const originalMethod = method
|
||
const originalData = options.data
|
||
const originalParams = options.params
|
||
|
||
let reqMethod = method
|
||
let reqData = options.data
|
||
const reqHeader = {
|
||
'Content-Type': 'application/json',
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
...(options.header || {})
|
||
}
|
||
// 便于在 HBuilderX/H5 的 Network 面板直接判断是否走了密文
|
||
reqHeader['X-Api-Crypto'] = shouldEncrypt ? 'on' : `off:${skipReason || 'unknown'}`
|
||
try {
|
||
if (shouldEncrypt) {
|
||
if (reqMethod === 'GET') {
|
||
reqHeader['X-HTTP-Method-Override'] = 'GET'
|
||
reqMethod = 'POST'
|
||
reqData = options.data || options.params || {}
|
||
}
|
||
const ts = Date.now()
|
||
const nonce = randomNonce()
|
||
const keyHex = await deriveKey(API_ENCRYPT_FIXED_SECRET, userId, ts, nonce)
|
||
const plainData = normalizeRequestData(reqData)
|
||
const edata = await encryptJson({ data: plainData }, keyHex)
|
||
reqData = {
|
||
enc: 1,
|
||
ts,
|
||
nonce,
|
||
edata
|
||
}
|
||
}
|
||
} catch (error) {
|
||
if (!API_ENCRYPT_FAIL_OPEN) {
|
||
reject(error)
|
||
return
|
||
}
|
||
}
|
||
|
||
uni.request({
|
||
...options,
|
||
method: reqMethod,
|
||
data: reqData,
|
||
url,
|
||
header: reqHeader,
|
||
success: async (res) => {
|
||
// 后端未启用密文中间件(或未支持 method override)时,GET->POST 传输会触发 405;
|
||
// 这里仅对“原始 GET + override”场景做一次降级回 GET,保证 H5 本地联调可用。
|
||
if (
|
||
res.statusCode === 405 &&
|
||
originalMethod === 'GET' &&
|
||
String(reqHeader['X-HTTP-Method-Override'] || '').toUpperCase() === 'GET'
|
||
) {
|
||
const fallbackHeader = {
|
||
...reqHeader
|
||
}
|
||
delete fallbackHeader['X-HTTP-Method-Override']
|
||
fallbackHeader['X-Api-Crypto'] = 'off:405-fallback'
|
||
uni.request({
|
||
...options,
|
||
method: 'GET',
|
||
data: originalData || originalParams,
|
||
url,
|
||
header: fallbackHeader,
|
||
success: (res2) => resolve(res2),
|
||
fail: (err2) => reject(err2)
|
||
})
|
||
return
|
||
}
|
||
|
||
let payload = res.data
|
||
if (shouldEncrypt && payload && payload.encrypted && payload.edata) {
|
||
try {
|
||
const respKeyHex = await deriveKey(
|
||
API_ENCRYPT_FIXED_SECRET,
|
||
userId,
|
||
Number(payload.ts),
|
||
String(payload.nonce || '')
|
||
)
|
||
payload = await decryptJson(payload.edata, respKeyHex)
|
||
res.data = payload
|
||
} catch (error) {
|
||
if (!API_ENCRYPT_FAIL_OPEN) {
|
||
reject(error)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
if (res.statusCode === 401 || isBizUnauthorized(payload)) {
|
||
onHttpUnauthorized()
|
||
reject(createUnauthorizedError(rawUrl))
|
||
return
|
||
}
|
||
resolve(res)
|
||
},
|
||
fail: (err) => reject(err)
|
||
})
|
||
})
|
||
}
|
||
|
||
export function parseResponseJson(res) {
|
||
const d = res.data
|
||
if (d == null) return d
|
||
if (typeof d === 'string') {
|
||
try {
|
||
return JSON.parse(d)
|
||
} catch (error) {
|
||
return d
|
||
}
|
||
}
|
||
return d
|
||
}
|