Files
pj_h5_cloud/src/sheep/utils/request.ts
T
2026-08-10 00:06:20 +08:00

510 lines
16 KiB
TypeScript
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 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<any> | null = null // 存储刷新token的Promise
let isRefreshing = false // 是否正在刷新
let failedQueue: any[] = [] // 队列存储发送失败的请求
/** App.onLaunch 与页面 ensureBizLogin 并发时复用同一次浙政钉登录,避免双重 showLoading/getAuthCode */
let zzdLoginPromise: Promise<"ok" | "sms" | "failed"> | 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<any> {
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<boolean> => {
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
}
function isZzdNeedSmsVerify(data: unknown): data is { needSmsVerify: true; bindSessionId: string; displayName?: string } {
if (!data || typeof data !== "object") return false
const o = data as Record<string, unknown>
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<boolean> {
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()
}
// 诗画登录
async function loginBySh(): Promise<boolean> {
try {
uni.showLoading({ title: "登录中...", mask: true })
sanitizeBizSession()
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 })
// const response = await userApi.authsh({ accountId: "67e790d6a95d5a7b507e53dd", sessionId: "68215b03a95d5a4899d20cb4" }) // 测试
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 */
}
}
}
/**
* 门户扫一扫等:有有效 token 直接过;否则按环境自动登录(诗画/浙政钉)。
* 与「我的」里先登录再扫码一致,保证后续支付接口带用户身份。
*/
export async function ensureBizLogin(): Promise<boolean> {
sanitizeBizSession()
const tokenStorage = uni.getStorageSync(TOKEN_KEY)
if (hasUsableBizToken(tokenStorage)) {
return true
}
return await autoLogin()
}
/**
* 进不去页时的「清缓存重试」:清 token/用户/诗画角色后重新自动登录。
* 保留 URL 上的 launchParam。
*/
export async function recoverBizSession(): Promise<boolean> {
clearBizSession({ clearRole: true })
zzdLoginPromise = null
refreshTokenPromise = null
isRefreshing = false
return await autoLogin()
}
// 请求函数
const request = (config: any) => {
return https.middleware(config)
}
export default request