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)