import Request from "luch-request" import { router, constant } from "@@/index" import { isEmpty } from "radash" import { getAppInfo, getUrlQueryParam, isDevEnv } from "@@/utils/app" import { userApi } from "../api" import showVerifyDeviceImpModel from "../components/verifyDeviceImpModel/verifyDeviceImpModel" import { APPENV } from "../constant" const TOKEN_KEY = constant.storageConstant.TOKEN const USER_INFO_KEY = constant.storageConstant.USERINFO const USER_ROLE_KEY = constant.storageConstant.USERROLE const SKIP_AUTH_URLS = ["/thirdparty/thirdPartyOAth2", "/sys/auth/refresh-token", "/auth/oauth2/token"] let refreshTokenPromise: Promise | null = null // 存储刷新token的Promise let isRefreshing = false // 是否正在刷新 let failedQueue: any[] = [] // 队列存储发送失败的请求 /** App.onLaunch 与页面 ensureBizLogin 并发时复用同一次浙政钉登录,避免双重 showLoading/getAuthCode */ let zzdLoginPromise: Promise<"ok" | "sms" | "failed"> | null = null /** 拦截器 refreshToken、「我的」checkAppInfo、页面 ensureBizLogin 并发时复用同一次自动登录 */ let autoLoginPromise: Promise | null = null /** 诗画 /shjnsso 并发互斥(与浙政钉 zzdLoginPromise 对称) */ let shLoginPromise: Promise | null = null /** * 清除本地登录相关缓存(等价于「重装清 WebView」里最关键的几项)。 * 默认保留 launchParam(入口参数);诗画卡角色时可 clearRole。 */ export function clearBizSession(options?: { clearRole?: boolean; clearLaunchParam?: boolean }) { try { uni.removeStorageSync(TOKEN_KEY) } catch { /* ignore */ } try { uni.removeStorageSync(USER_INFO_KEY) } catch { /* ignore */ } if (options?.clearRole) { try { uni.removeStorageSync(USER_ROLE_KEY) } catch { /* ignore */ } } if (options?.clearLaunchParam) { const fromUrl = getUrlQueryParam("launchParam") if (!fromUrl) { try { uni.removeStorageSync("launchParam") } catch { /* ignore */ } } } } /** 设备未信任占位,不是可请求的 token */ function isDeviceVerifyMarker(tokenStorage: any): boolean { return !!tokenStorage && typeof tokenStorage === "object" && tokenStorage.data == -1 } /** * 启动/登录前清理半残 token,避免「有 storage 但带不上 Authorization」导致白屏/进不去。 * 保留 data=-1(设备核验)标记。 */ export function sanitizeBizSession(): void { const tokenStorage = uni.getStorageSync(TOKEN_KEY) if (isEmpty(tokenStorage)) return if (isDeviceVerifyMarker(tokenStorage)) return if (!getTokenValue(tokenStorage) || isTokenExpires(tokenStorage)) { clearBizSession() } } function hasUsableBizToken(tokenStorage: any): boolean { if (isEmpty(tokenStorage) || isDeviceVerifyMarker(tokenStorage)) return false return !!getTokenValue(tokenStorage) && !isTokenExpires(tokenStorage) } // 处理队列中的请求 const processQueue = (error: any, token: any = null) => { failedQueue.forEach((prom) => { if (error) { prom.reject(error) } else { prom.resolve(token) } }) if (!error) { failedQueue = [] } } // 创建https请求实例 const https = new Request({ baseURL: import.meta.env.VITE_API_URL, timeout: 15000, method: "GET", header: { Accept: "*/*", "Content-Type": "application/json;charset=UTF-8", }, withCredentials: true, custom: { showSuccess: false, // 成功提醒 默认使用后端返回值 successMsg: "", // 成功消息 showError: true, // 显示失败消息 默认显示 errorMsg: "", // 失败提醒 默认使用后端返回信息 loadingMsg: "加载中", // loading提醒文字 auth: false, // 需要授权才能请求 默认放开 }, }) // 生产环境协议安全检查 const validateProductionSecurity = () => { if (isDevEnv()) return const apiUrl = import.meta.env.VITE_API_URL if (apiUrl && !apiUrl.startsWith("https://")) { uni.showToast({ title: "生产环境安全错误: 必须使用 HTTPS 协议", icon: "error" }) throw new Error("生产环境禁止使用 HTTP 协议") } } https.interceptors.request.use(async (config) => { // 生产环境协议检查 // validateProductionSecurity() const tokenStorage = uni.getStorageSync(TOKEN_KEY) const config_header: any = config.header || {} // 设置租户请求头 config_header["TENANT-ID"] = "1" if (import.meta.env.VITE_IS_TOC === "true") config_header["CLIENT-TOC"] = "Y" // 判断是否需要设置认证头(url 可能是完整地址,故用 includes) if (!SKIP_AUTH_URLS.some((url) => (config.url || "").includes(url))) { if (hasUsableBizToken(tokenStorage)) { const tokenValue = getTokenValue(tokenStorage) if (tokenValue) config_header.Authorization = tokenValue } else { // 无可用 token(含半残/过期/设备核验占位):清理半残后走自动登录 if (!isEmpty(tokenStorage) && !isDeviceVerifyMarker(tokenStorage)) { clearBizSession() } const newTokenStorage = await refreshToken() if (newTokenStorage) { const tokenValue = getTokenValue(newTokenStorage) if (tokenValue) config_header.Authorization = tokenValue } } } return config }, Promise.reject) // 响应拦截器 https.interceptors.response.use( async (response) => { // 账号密码登录:OAuth2 成功体无 code 字段,需包一层;url 可能是带 base 的完整路径 const reqUrl = response.config?.url || "" if (reqUrl.includes("/auth/oauth2/token")) { const d = response.data if (d && (d.access_token || d.accessToken?.tokenValue)) { return Promise.resolve({ code: 0, data: d, msg: null, }) } } return Promise.resolve(response.data) }, async (error) => { // 处理 424 错误(token过期) if (error.statusCode === 424) { if (isDevEnv()) { // 开发环境直接清除token并跳转登录 uni.removeStorageSync(TOKEN_KEY) uni.removeStorageSync(USER_INFO_KEY) uni.showToast({ title: "登录已过期,请重新登录", icon: "none" }) router.jump("/pages/user/login") } else { try { const newTokenStorage = await refreshToken() if (newTokenStorage) { const config = error.config const tokenValue = getTokenValue(newTokenStorage) if (tokenValue && config.header) config.header.Authorization = tokenValue return https.request(config) } clearBizSession() } catch (err) { clearBizSession() return Promise.reject(err) } } } // 显示错误信息 if (error?.data?.msg) { uni.showToast({ title: error.data.msg, icon: "none", mask: true }) } return Promise.resolve(error.data) }, ) // 从tokenStorage中读取tokenValue值(兼容嵌套 tokenValue / 诗画字符串 accessToken) const getTokenValue = (tokenStorage: any): string | null => { if (!tokenStorage || typeof tokenStorage !== "object") return null if (isDeviceVerifyMarker(tokenStorage)) return null const pick = (v: unknown): string | null => { if (typeof v === "string" && v.trim()) return v.trim() if (v && typeof v === "object" && typeof (v as { tokenValue?: unknown }).tokenValue === "string") { const tv = (v as { tokenValue: string }).tokenValue.trim() return tv || null } return null } const raw = pick(tokenStorage.accessToken) || pick(tokenStorage.access_token) || (typeof tokenStorage.tokenValue === "string" ? tokenStorage.tokenValue.trim() : null) return raw ? `Bearer ${raw}` : null } // 检查是否过期;无有效 token / 脏 expiresAt 一律视为过期,避免半残数据永远「未过期」 const isTokenExpires = (tokenStorage: any): boolean => { if (!tokenStorage || typeof tokenStorage !== "object") return true if (isDeviceVerifyMarker(tokenStorage)) return true if (!getTokenValue(tokenStorage)) return true const expiresAt = (tokenStorage.accessToken && typeof tokenStorage.accessToken === "object" ? tokenStorage.accessToken.expiresAt : null) ?? (tokenStorage.access_token && typeof tokenStorage.access_token === "object" ? tokenStorage.access_token.expiresAt : null) if (expiresAt == null || expiresAt === "") { // 有 token 但无过期字段(部分诗画落库):先用,等 424 再刷 return false } const expiresDate = new Date(expiresAt).getTime() if (Number.isNaN(expiresDate)) return true return Date.now() >= expiresDate } async function refreshToken(): Promise { if (refreshTokenPromise) return refreshTokenPromise if (!isRefreshing) { isRefreshing = true refreshTokenPromise = (async () => { try { const loginResult = await autoLogin() if (loginResult) { const newTokenStorage = uni.getStorageSync(TOKEN_KEY) processQueue(null, newTokenStorage) return newTokenStorage } else { // 检查是否需要设备验证 const tokenStorage = uni.getStorageSync(TOKEN_KEY) if (tokenStorage?.data == -1) { // 显示设备验证弹窗 await showVerifyDeviceImpModel(async () => { // 验证完成后重新登录 const newLoginResult = await autoLogin() if (newLoginResult) { const newTokenStorage = uni.getStorageSync(TOKEN_KEY) processQueue(null, newTokenStorage) return newTokenStorage } else { processQueue(new Error("登录失败"), null) throw new Error("登录失败") } }) } else { processQueue(new Error("登录失败"), null) } } } catch (error) { processQueue(error, null) throw error } finally { isRefreshing = false refreshTokenPromise = null } })() } return refreshTokenPromise } // 根据应用环境自动登录 返回登录结果(全局互斥,避免清缓存后并发打多遍单点) const autoLogin = async (): Promise => { if (autoLoginPromise) return autoLoginPromise autoLoginPromise = (async () => { try { const appInfo = await getAppInfo() if (appInfo == APPENV.ZZD) return await loginByZzd() if (appInfo == APPENV.SH) return await loginBySh() uni.showToast({ title: "app环境错误", icon: "none" }) return false } finally { autoLoginPromise = null } })() return autoLoginPromise } function isZzdNeedSmsVerify(data: unknown): data is { needSmsVerify: true; bindSessionId: string; displayName?: string } { if (!data || typeof data !== "object") return false const o = data as Record const need = o.needSmsVerify === true || o.needSmsVerify === "true" || o.needSmsVerify === 1 || o.needSmsVerify === "1" const sid = o.bindSessionId const sidStr = typeof sid === "string" ? sid : sid != null ? String(sid) : "" return need && sidStr.length > 0 } function goZzdSmsVerify(data: { bindSessionId: string; displayName?: string }) { const sid = typeof data.bindSessionId === "string" ? data.bindSessionId : String(data.bindSessionId || "") const name = typeof data.displayName === "string" ? data.displayName : "" const q = `bindSessionId=${encodeURIComponent(sid)}&displayName=${encodeURIComponent(name)}` uni.reLaunch({ url: `/pages/user/zzdSmsVerify?${q}` }) } /** * 浙政钉专用:校验本地 token → 无效则 getAuthCode→zzdsso → 需绑手机则去短信页 → 否则换 token。 * @returns ok 已可带 token 请求;sms 已跳转核验页;failed 未登录 */ async function zzdLoginCore(): Promise<"ok" | "sms" | "failed"> { if (zzdLoginPromise) return zzdLoginPromise zzdLoginPromise = (async () => { try { uni.showLoading({ title: "登录中...", mask: true }) sanitizeBizSession() const tokenStorage = uni.getStorageSync(TOKEN_KEY) if (hasUsableBizToken(tokenStorage)) { return "ok" } if (typeof window !== "undefined") { try { const { requestZzdAuthCodeViaGdt } = await import("./dingAuthCode") const corpId = (import.meta.env.VITE_ZZD_CORP_ID || "").trim() const authCode = await requestZzdAuthCodeViaGdt(corpId) const res: any = await userApi.auth({ authCode }) if (res?.code == 0) { if (isZzdNeedSmsVerify(res.data)) { goZzdSmsVerify(res.data as { bindSessionId: string; displayName?: string }) return "sms" } const d = res.data if (!d || typeof d !== "object" || (!d.accessToken && !d.refreshToken)) { clearBizSession() uni.showToast({ title: "登录返回数据异常,请重试", icon: "none", duration: 4000 }) return "failed" } uni.setStorageSync(TOKEN_KEY, res.data) const { data: userData } = await userApi.userInfo() uni.setStorageSync(USER_INFO_KEY, userData) const { onZzdLoginSuccessTrack } = await import("./zzd-aplus-track") void onZzdLoginSuccessTrack() return "ok" } clearBizSession() if (res?.msg) { uni.showToast({ title: res.msg, icon: "none", duration: 4000 }) } return "failed" } catch (e) { console.warn("[loginByZzd] authCode 登录未成功,尝试 uabaseToken", e) } } const token = typeof window !== "undefined" ? getUrlQueryParam("uabaseToken") || null : null if (!token) { clearBizSession() uni.showToast({ title: "用户凭证已失效,请重新进入智慧后勤应用", icon: "none", duration: 6000 }) return "failed" } const res = await userApi.auth({ token }) if (res?.code == 0) { uni.setStorageSync(TOKEN_KEY, res.data) const { data: userData } = await userApi.userInfo() uni.setStorageSync(USER_INFO_KEY, userData) const { onZzdLoginSuccessTrack } = await import("./zzd-aplus-track") void onZzdLoginSuccessTrack() return "ok" } else { clearBizSession() if (res.msg == "token已失效") { uni.showToast({ title: "用户凭证已失效,请退出并重新进入智慧后勤应用", icon: "none", duration: 6000 }) } return "failed" } } catch (error: any) { clearBizSession() uni.showToast({ title: error, icon: "none" }) return "failed" } finally { try { uni.hideLoading() } catch { /* ignore */ } zzdLoginPromise = null } })() return zzdLoginPromise } async function loginByZzd(): Promise { const r = await zzdLoginCore() return r === "ok" } /** * 从门户/常用入口进入 H5 时主动跑浙政钉单点。非浙政钉环境立即返回 ok,不调用 zzdsso、不碰诗画逻辑。 */ export async function tryZzdEntryLogin(): Promise<"ok" | "sms" | "failed"> { sanitizeBizSession() const appInfo = await getAppInfo() if (appInfo !== APPENV.ZZD) return "ok" return await zzdLoginCore() } // 诗画登录(互斥:避免「我的」与请求拦截器各打一遍 /shjnsso) async function loginBySh(): Promise { if (shLoginPromise) return shLoginPromise shLoginPromise = (async () => { try { uni.showLoading({ title: "登录中...", mask: true }) sanitizeBizSession() const tokenStorage = uni.getStorageSync(TOKEN_KEY) if (hasUsableBizToken(tokenStorage)) { return true } const userInfo = await (window as any).CP2.getUserInfo() if (userInfo.code == 0 || !userInfo.data.accountId || !userInfo.data.sessionId) { uni.showToast({ title: "获取诗画用户信息失败", icon: "none" }) return false } const { accountId, sessionId } = userInfo.data const response = await userApi.authsh({ accountId, sessionId }) if (response.code == 0) { const at = response.data?.accessToken // 统一成可被 getTokenValue 识别的结构,避免只落了字符串导致后续读不到 if (typeof at === "string") { uni.setStorageSync(TOKEN_KEY, { accessToken: { tokenValue: at } }) } else { uni.setStorageSync(TOKEN_KEY, { accessToken: at }) } const { data: userData } = await userApi.userInfo() uni.setStorageSync(USER_INFO_KEY, userData) uni.showToast({ title: "登录成功", icon: "none" }) return true } else { clearBizSession() if (response.msg == "token已失效") { uni.showToast({ title: "用户凭证已失效,请退出并重新进入智慧后勤应用", icon: "none", duration: 6000 }) return false } // 设备未信任:后端 R.failed(-1, msg) 业务码在 code,data 为 null if (response.code == -1) { uni.setStorageSync(TOKEN_KEY, { data: -1 }) } uni.showToast({ title: response.msg || "登录失败", icon: "none" }) return false } } catch (error) { clearBizSession() uni.showToast({ title: "登录错误", icon: "none" }) return false } finally { try { uni.hideLoading() } catch { /* ignore */ } shLoginPromise = null } })() return shLoginPromise } /** * 门户扫一扫等:有有效 token 直接过;否则按环境自动登录(诗画/浙政钉)。 * 与「我的」里先登录再扫码一致,保证后续支付接口带用户身份。 */ export async function ensureBizLogin(): Promise { sanitizeBizSession() const tokenStorage = uni.getStorageSync(TOKEN_KEY) if (hasUsableBizToken(tokenStorage)) { return true } return await autoLogin() } /** * 进不去页时的「清缓存重试」:清 token/用户/诗画角色后重新自动登录。 * 保留 URL 上的 launchParam。 */ export async function recoverBizSession(): Promise { clearBizSession({ clearRole: true }) zzdLoginPromise = null shLoginPromise = null autoLoginPromise = null refreshTokenPromise = null isRefreshing = false return await autoLogin() } // 请求函数 const request = (config: any) => { return https.middleware(config) } export default request