| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384 |
- 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
- }
- function getStoredToken() {
- const token = uni.getStorageSync(TOKEN_KEY)
- return typeof token === 'string' ? token.trim() : ''
- }
- async function onHttpUnauthorized(requestToken) {
- // 旧请求的失效响应不能清理用户随后登录的新会话。
- if (clearingAuth || getStoredToken() !== requestToken) 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')
- if (getStoredToken()) {
- clearingAuth = false
- return
- }
- useUserStore().clearLoginInfo()
- } catch (error) {
- // ignore
- }
- if (getStoredToken()) {
- clearingAuth = false
- return
- }
- 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 兜底
- if (!getStoredToken()) 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 token = getStoredToken()
- 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(token)
- 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
- }
|