/** * uni.request 封装 — 双 Token 无感刷新 + 并发请求队列 * * 网络环境选择(三级优先级): * 1. env_base_url 缓存(App 内运行时动态切换,最高优先级) * 2. process.env.NODE_ENV 自动判断(development → 本地 IP / production → 公网域名) * 3. 生产默认 PROD_URL */ // 域名/环境地址统一来自 utils/config.js,勿在此处硬编码 import { PROD_URL, LOCAL_URL } from "./config"; // ============================================================ // 环境 URL(自动感知:缓存配置 > NODE_ENV > 生产默认) // ============================================================ export function getBaseUrl() { // 1. 最高优先级:动态切换的缓存配置(如通过 App 内隐藏菜单切换) const envUrl = uni.getStorageSync("env_base_url"); if (envUrl) { return envUrl; } // 2. 自动环境判断 if (process.env.NODE_ENV === 'development') { // 本地运行/真机调试时,自动使用本地开发机 IP return LOCAL_URL; } // 3. 生产打包发行时,自动使用公网域名 return PROD_URL; } // ============================================================ // 双 Token 存储 // ============================================================ function getAccessToken() { return uni.getStorageSync("access_token") || ""; } function getRefreshToken() { return uni.getStorageSync("refresh_token") || ""; } function setTokens(accessToken, refreshToken) { uni.setStorageSync("access_token", accessToken); uni.setStorageSync("refresh_token", refreshToken); } function clearTokens() { uni.removeStorageSync("access_token"); uni.removeStorageSync("refresh_token"); uni.removeStorageSync("token"); uni.removeStorageSync("user"); } // ============================================================ // 并发请求队列 // ============================================================ let isRefreshing = false; let retryQueue = []; function processQueue(error, newToken) { retryQueue.forEach((p) => { if (newToken) p.resolve(newToken); else p.reject(error); }); retryQueue = []; } async function refreshAccessToken() { const refreshToken = getRefreshToken(); if (!refreshToken) throw new Error("无 Refresh Token"); return new Promise((resolve, reject) => { uni.request({ url: getBaseUrl() + "/auth/refresh", method: "POST", data: { refresh_token: refreshToken }, header: { "Content-Type": "application/json" }, timeout: 10000, success(res) { if (res.statusCode >= 200 && res.statusCode < 300) resolve(res.data); else reject(res); }, fail(err) { reject(err); }, }); }); } // ============================================================ // 错误文案提取 — 把后端 detail 统一转成能读的中文,杜绝 [object Object] // ============================================================ // // FastAPI 的 detail 有三种形态,直接丢给 showToast 会变成 [object Object]: // 1. 字符串: {"detail": "任务状态为 COMPLETED,无法驳回"} // 2. 校验错误数组(422): {"detail": [{"loc": ["body","images"], "msg": "Value error, 驳回必须...", "type": "value_error"}]} // 3. 对象: {"detail": {"msg": "..."}} export function extractErrorDetail(data, fallback = "") { const detail = data && data.detail; if (!detail) return (data && data.msg) || fallback; if (typeof detail === "string") return detail || fallback; if (Array.isArray(detail)) { const msgs = detail.map((item) => { if (typeof item === "string") return item; if (!item || typeof item !== "object") return ""; // Pydantic v2 自定义校验器抛的错带 "Value error, " 前缀,读起来噪音大,去掉 const msg = String(item.msg || item.message || "").replace(/^Value error,\s*/, ""); const field = Array.isArray(item.loc) ? item.loc.filter((p) => !["body", "query", "path"].includes(p)).join(".") : ""; return field && msg ? `${field}: ${msg}` : msg; }).filter(Boolean); return msgs.length ? msgs.join(";") : fallback; } if (typeof detail === "object") { return detail.msg || detail.message || JSON.stringify(detail); } return String(detail) || fallback; } export default function request(options) { return new Promise((resolve, reject) => { const url = options.url.startsWith("http") ? options.url : getBaseUrl() + options.url; const accessToken = getAccessToken(); uni.request({ url, method: options.method || "GET", data: options.data || {}, header: { "Content-Type": "application/json", ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), ...(options.header || {}), }, timeout: 15000, success(res) { const code = res.statusCode; if (code >= 200 && code < 300) { resolve(res.data); return; } if (code === 401) { if (url.includes("/auth/refresh") || url.includes("/auth/login")) { reject(res); return; } if (isRefreshing) { retryQueue.push({ resolve: (newToken) => { options.header = options.header || {}; options.header.Authorization = `Bearer ${newToken}`; request(options).then(resolve).catch(reject); }, reject: (err) => reject(err), }); return; } isRefreshing = true; refreshAccessToken() .then((data) => { setTokens(data.access_token, getRefreshToken()); processQueue(null, data.access_token); options.header = options.header || {}; options.header.Authorization = `Bearer ${data.access_token}`; request(options).then(resolve).catch(reject); }) .catch(() => { console.error("[Request] Refresh Token 也过期,清除登录态"); processQueue(new Error("refresh_failed"), null); clearTokens(); uni.showToast({ title: "登录已过期,请重新登录", icon: "none" }); setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000); reject(res); }) .finally(() => { isRefreshing = false; }); return; } // silent:调用方自行兜底提示时(如 feedback 的 404 回退 mock)跳过通用 toast, // 否则用户会先看到一句「请求失败 (404)」,再看到「感谢反馈」,自相矛盾。 if (!options.silent) { if (code === 403) uni.showToast({ title: extractErrorDetail(res.data, "无权操作"), icon: "none", duration: 3000 }); else if (code === 400) uni.showToast({ title: extractErrorDetail(res.data, "请求参数有误"), icon: "none", duration: 2500 }); else if (code === 409) uni.showToast({ title: extractErrorDetail(res.data, "操作冲突"), icon: "none", duration: 3000 }); else if (code === 422) { // 表单/参数校验失败(Pydantic)—— detail 是校验项数组,必须解析出 msg 再提示 uni.showToast({ title: extractErrorDetail(res.data, "提交内容不符合要求"), icon: "none", duration: 3000 }); } else uni.showToast({ title: extractErrorDetail(res.data, `请求失败 (${code})`), icon: "none", duration: 3000 }); } reject(res); }, fail(err) { // ⚠️ fail = 压根没拿到后端响应(超时/断网),与 4xx/5xx 有本质区别: // 后端可能已经执行成功,只是响应没回来。故保留原始 errMsg 并打上标记, // 让上层能识别出「网络级失败」从而打断用户的连点重试。 const errMsg = (err && (err.errMsg || err.message)) || ""; const isTimeout = /timeout/i.test(errMsg); if (!options.silent) { uni.showToast({ title: isTimeout ? "网络超时,请稍后重试" : "网络连接失败", icon: "none" }); } const e = new Error(errMsg || "network"); e.errMsg = errMsg; e.isNetworkError = true; e.isTimeout = isTimeout; reject(e); }, }); }); } export function get(url, params = {}) { const query = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&"); return request({ url: query ? `${url}?${query}` : url, method: "GET" }); } export function post(url, data = {}) { return request({ url, method: "POST", data }); } export function patch(url, data = {}) { return request({ url, method: "PATCH", data }); } export function put(url, data = {}) { return request({ url, method: "PUT", data }); }