Files
shanxia2.0/miniprogram/utils/request.js
T

372 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
)
.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
}