From f4adf95e7afd19668e993dbb6f3f000c8a1ac852 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Fri, 21 Aug 2026 12:20:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=B0=8F=E7=A8=8B=E5=BA=8F=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 38 +++---- src/pages.json | 3 +- src/pages/root/pwd-login.vue | 3 +- src/pages/tabbar/my.vue | 18 +++- src/pages/third-app/happyMall.vue | 32 +++++- src/sheep/hooks/use-token.ts | 17 ++-- src/sheep/stores/tabbar.ts | 2 +- src/sheep/utils/request.ts | 159 +++++++++++++++++++++++------- 8 files changed, 203 insertions(+), 69 deletions(-) diff --git a/index.html b/index.html index f8a54cb..c1e9854 100644 --- a/index.html +++ b/index.html @@ -1,20 +1,22 @@ - + - - - - - - - - -
- - + + + + + + + + +
+ + diff --git a/src/pages.json b/src/pages.json index 2648cc5..a1f0098 100644 --- a/src/pages.json +++ b/src/pages.json @@ -97,7 +97,8 @@ "path": "pages/root/web-view", "style": { "navigationBarBackgroundColor": "#ffffff", - "navigationBarTitleText": "加载中...", + "navigationBarTitleText": "美好商城", + "navigationStyle": "default", "disableScroll": true } }, diff --git a/src/pages/root/pwd-login.vue b/src/pages/root/pwd-login.vue index cc6a80e..f838407 100644 --- a/src/pages/root/pwd-login.vue +++ b/src/pages/root/pwd-login.vue @@ -63,7 +63,8 @@ const doSubmit = async () => { uni.setStorageSync(tokenKey, { accessToken: res.data.access_token, refreshToken: res.data.refresh_token, - accessTokenExpiresIn: new Date(res.data.expires_in + Date.now()).toISOString(), + // OAuth expires_in 的单位是秒,Date.now() 的单位是毫秒,必须先乘 1000。 + accessTokenExpiresIn: new Date(Date.now() + Number(res.data.expires_in) * 1000).toISOString(), refreshTokenExpiresIn: "", } as GlobalSetting.TokenStorage) diff --git a/src/pages/tabbar/my.vue b/src/pages/tabbar/my.vue index fa36e0b..b0797d7 100644 --- a/src/pages/tabbar/my.vue +++ b/src/pages/tabbar/my.vue @@ -82,6 +82,7 @@ import { isEmpty } from "radash" import dayjs from "dayjs" import { filePath } from "@@/utils/file" import { getAppInfo } from "@@/utils/app" +import { normalizeTokenStorage } from "@@/utils/request" import * as hooks from "@@/hooks" import * as router from "@@/router" import * as constant from "@@/constant" @@ -136,7 +137,11 @@ const storage = { // 保存token到存储 saveToken: (tokenData: any) => { - uni.setStorageSync(STORAGE_KEYS.TOKEN, tokenData) + // SSO 接口可能返回嵌套 token,统一落成请求层使用的扁平结构。 + const normalizedToken = normalizeTokenStorage(tokenData) + if (!normalizedToken) return false + uni.setStorageSync(STORAGE_KEYS.TOKEN, normalizedToken) + return true }, // 清除用户登录相关的存储 @@ -202,7 +207,11 @@ const login = { const response = await userApi.authsh({ accountId, sessionId }) if (response.code == 0) { - storage.saveToken({ accessToken: response.data.accessToken }) + if (!storage.saveToken(response.data)) { + uni.hideLoading() + uni.showToast({ title: "登录返回数据异常", icon: "none" }) + return + } uni.hideLoading() uni.showToast({ title: "登录成功", icon: "none" }) @@ -226,7 +235,10 @@ const login = { const res = await userApi.auth(token) if (res.code == 0) { - storage.saveToken(res.data) + if (!storage.saveToken(res.data)) { + uni.showToast({ title: "登录返回数据异常", icon: "none" }) + return + } uni.hideLoading() uni.showToast({ title: "登录成功", icon: "none" }) diff --git a/src/pages/third-app/happyMall.vue b/src/pages/third-app/happyMall.vue index c29c7da..5bf377d 100644 --- a/src/pages/third-app/happyMall.vue +++ b/src/pages/third-app/happyMall.vue @@ -27,6 +27,33 @@ import { filePath } from "@@/utils/file" const show = ref(false) +function normalizeMallUrl(rawUrl: string) { + const matched = rawUrl.match(/^(https:\/\/(?:[a-z0-9-]+\.)*nbcb\.com\.cn)(\/[^?#]*)?(\?[^#]*)?(#.*)?$/i) + if (!matched) throw new Error("商城登录地址不合法") + + const [, origin, rawPathname = "/", search = "", hash = ""] = matched + const queryItems = search.slice(1).split("&").filter(Boolean) + const queryKeys = queryItems.map((item) => decodeURIComponent(item.split("=")[0] || "")) + const hasToken = queryItems.some((item) => /^token=.+/i.test(item)) + let pathname = rawPathname + + // 宁波银行正常登录页是 /h5/thirdApp;部分环境会错误返回带 token 的根路径。 + if (pathname === "/" && hasToken) { + pathname = "/h5/thirdApp" + } + + console.log("[happyMall] 商城登录地址:", { + origin, + pathname, + queryKeys, + }) + + if (pathname === "/" || !hasToken) { + throw new Error("商城登录地址异常,请联系管理员") + } + return `${origin}${pathname}${search}${hash}` +} + onLoad(async () => { const isLogin = await hooks.useCheckLogin() if (isLogin) { @@ -42,10 +69,11 @@ const thirdAppJump = async () => { if (res.code == 0) { show.value = false if (!res.data) return uni.showToast({ title: "url错误", icon: "none" }) - router.jump("/pages/root/web-view", { url: encodeURIComponent(res.data) }, 2) + const mallUrl = normalizeMallUrl(res.data) + router.jump("/pages/root/web-view", { url: encodeURIComponent(mallUrl) }, 2) } } catch (error: any) { - uni.showToast({ title: error, icon: "none" }) + uni.showToast({ title: error?.message || String(error), icon: "none" }) } finally { uni.hideLoading() } diff --git a/src/sheep/hooks/use-token.ts b/src/sheep/hooks/use-token.ts index 46310a2..b6ba3be 100644 --- a/src/sheep/hooks/use-token.ts +++ b/src/sheep/hooks/use-token.ts @@ -1,11 +1,13 @@ import storageConstant from "../constant/storage-constant" import { jump } from "../router/index" -import { isDevEnv } from "../utils/app" export const useToken = () => { const tokenKey = storageConstant.TOKEN const tokenStorage = uni.getStorageSync(tokenKey) - return tokenStorage?.access_token ? `Bearer ${tokenStorage.access_token}` : "" + // 登录与刷新接口历史上存在两种字段格式,上传等原生请求也必须读取同一个有效 token。 + const rawToken = tokenStorage?.accessToken ?? tokenStorage?.access_token + const tokenValue = typeof rawToken === "string" ? rawToken : rawToken?.tokenValue + return tokenValue ? `Bearer ${tokenValue}` : "" } export const useDefaultHeader = () => { @@ -18,17 +20,12 @@ export const useDefaultHeader = () => { } export const useStaticCheckLogin = () => { - const tokenKey = storageConstant.TOKEN - const tokenStorage = uni.getStorageSync(tokenKey) - if (tokenStorage) return true - return false + // 不能只判断缓存对象存在,刷新失败留下的空对象不代表已登录。 + return Boolean(useToken()) } export const useCheckLogin = async (): Promise => { - const tokenKey = storageConstant.TOKEN - const tokenStorage = uni.getStorageSync(tokenKey) - - if (tokenStorage) return true + if (useToken()) return true uni.showModal({ title: "提示", content: "您还未登录,请先登录", diff --git a/src/sheep/stores/tabbar.ts b/src/sheep/stores/tabbar.ts index bb46fed..dae372b 100644 --- a/src/sheep/stores/tabbar.ts +++ b/src/sheep/stores/tabbar.ts @@ -220,7 +220,7 @@ export const useTabbarStore = defineStore("tabbarStore", { if (!firstTabbar) { return } - jump(firstTabbar.pagePath, {}, 3) + jump(firstTabbar.pagePath, {}) }, setCurrentRoute(route: string) { if (route === this.currentRoute) { diff --git a/src/sheep/utils/request.ts b/src/sheep/utils/request.ts index e075d25..85aea12 100644 --- a/src/sheep/utils/request.ts +++ b/src/sheep/utils/request.ts @@ -12,13 +12,16 @@ const SKIP_AUTH_URLS = [ "/thirdparty/thirdPartyOAth2", "/sys/auth/refresh-token", "/auth/oauth2/token", + "/third/third-mini-app-oath/auto-admin-login", "/admin/user/forgot-password", "/admin/stats/access/track", ] const SKIP_REFRESH_TOKEN = ["/sys/auth/refresh-token", "/auth/token/check_token"] +const AUTH_EXPIRED_CODES = [401, 424] +const REFRESH_AHEAD_MS = 60 * 1000 -let refreshTokenLock = true -let refreshTokenPromise: Promise | null = null +let refreshTokenPromise: Promise | null = null +let loginExpiredHandled = false const BASIC_AUTH = "Basic " + base64.encode(import.meta.env.VITE_OAUTH2_APP_CLIENT) const buildErrorResponse = (error: any, fallbackMsg: string = "请求失败") => ({ code: error?.statusCode || -1, @@ -58,14 +61,14 @@ https.interceptors.request.use(async (config) => { // 判断是否需要设置认证头 if (!SKIP_AUTH_URLS.some((url) => config.url?.startsWith(url))) { if (!isEmpty(tokenStorage)) { - // token存在,检查是否过期 - if (isTokenExpires(tokenStorage) && !SKIP_REFRESH_TOKEN.some((url) => config.url?.startsWith(url)) && refreshTokenLock) { - // 过期,刷新token + // 提前一分钟刷新,避免 token 在请求传输或服务端处理过程中到期。 + if (isTokenExpires(tokenStorage) && !SKIP_REFRESH_TOKEN.some((url) => config.url?.startsWith(url))) { + // 所有并发请求都会等待同一个 refreshTokenPromise,不能让后续请求继续携带旧 token。 const newTokenStorage = await refreshToken() if (newTokenStorage) { tokenValue = getTokenValue(newTokenStorage) - if (tokenValue) config_header.Authorization = tokenValue } else { + config.custom = { ...(config.custom || {}), authRefreshAttempted: true } return Promise.reject({ statusCode: 424, errMsg: "refresh token failed", @@ -75,7 +78,12 @@ https.interceptors.request.use(async (config) => { } } } - config_header["Authorization"] = tokenValue + if (tokenValue) { + config_header.Authorization = tokenValue + loginExpiredHandled = false + } else { + delete config_header.Authorization + } } config.header = config_header return config @@ -92,17 +100,27 @@ https.interceptors.response.use( msg: null, }) } + // 部分网关以 HTTP 200 + 业务码返回 token 过期,也要进入同一套刷新和重试流程。 + if ( + AUTH_EXPIRED_CODES.includes(Number(response.data?.code)) && + !SKIP_AUTH_URLS.some((url) => response.config.url?.startsWith(url)) + ) { + return recoverExpiredRequest({ + statusCode: Number(response.data.code), + data: response.data, + config: response.config, + }) + } return Promise.resolve(response.data) }, async (error) => { uni.hideLoading() - // 处理 424 错误(token过期) - if (error.statusCode === 424) { - // 判断是否在登录页面 - uni.removeStorageSync(TOKEN_KEY) - uni.removeStorageSync(USER_INFO_KEY) - uni.showToast({ title: "登录已过期,请重新登录", icon: "none" }) - router.jump("/pages/root/login") + // HTTP 401/424 先刷新并重放原请求,只有刷新失败才要求重新登录。 + if ( + AUTH_EXPIRED_CODES.includes(Number(error.statusCode)) && + !SKIP_AUTH_URLS.some((url) => error.config?.url?.startsWith(url)) + ) { + return recoverExpiredRequest(error) } // 显示错误信息 else if (error?.data?.msg) { @@ -112,24 +130,71 @@ https.interceptors.response.use( }, ) +const unwrapTokenValue = (value: any): string | null => { + if (typeof value === "string" && value.trim()) return value.trim() + if (value && typeof value.tokenValue === "string" && value.tokenValue.trim()) return value.tokenValue.trim() + return null +} + // 从tokenStorage中读取tokenValue值 const getTokenValue = (tokenStorage: GlobalSetting.TokenStorage): string | null => { - return tokenStorage?.accessToken ? `Bearer ${tokenStorage?.accessToken}` : null + const value = unwrapTokenValue(tokenStorage?.accessToken) || unwrapTokenValue((tokenStorage as any)?.access_token) + return value ? `Bearer ${value}` : null +} + +const getRefreshTokenValue = (tokenStorage: GlobalSetting.TokenStorage): string | null => { + return unwrapTokenValue(tokenStorage?.refreshToken) || unwrapTokenValue((tokenStorage as any)?.refresh_token) +} + +const getExpiresAt = (tokenStorage: any, tokenKey: "accessToken" | "refreshToken", expiresKey: string): number => { + const raw = tokenStorage?.[expiresKey] || tokenStorage?.[tokenKey]?.expiresAt + if (!raw) return Number.NaN + return new Date(raw).getTime() } // 检查tokenStorage中tokenValue是否过期 const isTokenExpires = (tokenStorage: GlobalSetting.TokenStorage): boolean => { - const { accessTokenExpiresIn } = tokenStorage - const expiresDate = new Date(accessTokenExpiresIn).getTime() - return new Date().getTime() >= expiresDate + const expiresAt = getExpiresAt(tokenStorage, "accessToken", "accessTokenExpiresIn") + // 老版本缓存没有过期时间时交给服务端 401/424 兜底,避免把仍可用的 token 直接踢下线。 + if (!Number.isFinite(expiresAt)) return false + return Date.now() + REFRESH_AHEAD_MS >= expiresAt } -async function refreshToken(): Promise { +const expiresInSecondsToIso = (seconds: any): string => { + const value = Number(seconds) + return Number.isFinite(value) && value > 0 ? new Date(Date.now() + value * 1000).toISOString() : "" +} + +/** 将 OAuth 下划线格式和后台嵌套格式统一为项目使用的 TokenStorage。 */ +export const normalizeTokenStorage = (data: any, previous?: GlobalSetting.TokenStorage): GlobalSetting.TokenStorage | null => { + const accessToken = unwrapTokenValue(data?.accessToken) || unwrapTokenValue(data?.access_token) + if (!accessToken) return null + + const refreshToken = + unwrapTokenValue(data?.refreshToken) || unwrapTokenValue(data?.refresh_token) || (previous ? getRefreshTokenValue(previous) : null) || "" + const accessTokenExpiresIn = + data?.accessTokenExpiresIn || data?.accessToken?.expiresAt || expiresInSecondsToIso(data?.expires_in) + const refreshTokenExpiresIn = + data?.refreshTokenExpiresIn || + data?.refreshToken?.expiresAt || + expiresInSecondsToIso(data?.refresh_expires_in) || + previous?.refreshTokenExpiresIn || + "" + + return { accessToken, refreshToken, accessTokenExpiresIn, refreshTokenExpiresIn } +} + +async function refreshToken(): Promise { if (refreshTokenPromise) return refreshTokenPromise refreshTokenPromise = (async () => { - refreshTokenLock = false try { const tokenStorage = uni.getStorageSync(TOKEN_KEY) as GlobalSetting.TokenStorage + const refreshTokenValue = getRefreshTokenValue(tokenStorage) + if (!refreshTokenValue) return null + + const refreshExpiresAt = getExpiresAt(tokenStorage, "refreshToken", "refreshTokenExpiresIn") + if (Number.isFinite(refreshExpiresAt) && Date.now() >= refreshExpiresAt) return null + const res: any = await https.middleware({ url: "/auth/oauth2/token", header: { @@ -138,28 +203,56 @@ async function refreshToken(): Promise { "Content-Type": "application/x-www-form-urlencoded", }, method: "POST", - params: { refresh_token: tokenStorage.refreshToken, grant_type: "refresh_token", scope: "server" }, + params: { refresh_token: refreshTokenValue, grant_type: "refresh_token", scope: "server" }, }) - const { data } = res || {} - if (data) { - tokenStorage.accessToken = data?.accessToken?.tokenValue - tokenStorage.refreshToken = data?.refreshToken?.tokenValue - tokenStorage.accessTokenExpiresIn = data?.accessToken?.expiresAt - tokenStorage.refreshTokenExpiresIn = data?.refreshToken?.expiresAt - uni.setStorageSync(TOKEN_KEY, tokenStorage) - return tokenStorage - } - return null + const newTokenStorage = res?.code === 0 ? normalizeTokenStorage(res.data, tokenStorage) : null + if (!newTokenStorage) return null + + uni.setStorageSync(TOKEN_KEY, newTokenStorage) + loginExpiredHandled = false + return newTokenStorage } catch (error) { - return false + return null } finally { - refreshTokenLock = true refreshTokenPromise = null } })() return refreshTokenPromise } +const clearExpiredSession = () => { + // 多个并发请求同时失败时只提示和跳转一次。 + if (loginExpiredHandled) return + loginExpiredHandled = true + uni.removeStorageSync(TOKEN_KEY) + uni.removeStorageSync(USER_INFO_KEY) + uni.showToast({ title: "登录已过期,请重新登录", icon: "none" }) + router.jump("/pages/root/login") +} + +const recoverExpiredRequest = async (error: any) => { + const config = error?.config || {} + const requestUrl = config.url || "" + const custom = config.custom || {} + const canRefresh = !SKIP_AUTH_URLS.some((url) => requestUrl.startsWith(url)) && !custom.authRefreshAttempted + + if (canRefresh) { + const newTokenStorage = await refreshToken() + const authorization = newTokenStorage ? getTokenValue(newTokenStorage) : null + if (authorization) { + // 标记只重试一次,避免服务端持续返回 401/424 时形成请求死循环。 + return https.middleware({ + ...config, + header: { ...(config.header || {}), Authorization: authorization }, + custom: { ...custom, authRefreshAttempted: true }, + }) + } + } + + clearExpiredSession() + return buildErrorResponse(error, "登录已过期,请重新登录") +} + // 请求函数 const request = (config: any) => { return https.middleware(config)