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' ) .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 }