From 354232b44484ce1db1b8fd44c8ad822708eaeaff Mon Sep 17 00:00:00 2001 From: DEV_DSW <562304744@qq.com> Date: Thu, 4 Jun 2026 11:07:30 +0800 Subject: [PATCH] refactor: overhaul auth system, routing, and navigation flows This commit overhauls several core parts of the codebase: - Replace legacy localStorage token storage with secure cookie-based authentication with automatic token refresh logic - Rewrite checkToken and goLogin hooks to support redirect targets and proper async error handling - Update all component navigation flows to use the updated auth checks and correct redirect behavior - Refactor router configuration to support both client-side and server-side rendering via conditional history usage - Remove unused speech recognition constant file and stale component registration for RecordingWaveBtn - Add unit tests for navigation utilities and auth token handling - Fix login post-success redirect logic and add login success event emissions - Overhaul HTTP request handling to include automatic auth token injection and retry on unauthorized errors --- components.d.ts | 2 - src/api/login.ts | 31 +- src/constants/speech.ts | 7 - src/constants/token.test.ts | 120 ++++++++ src/constants/token.ts | 273 +++++++++++++++++- src/hooks/useGoLogin.ts | 25 +- src/hooks/useNavigator.test.ts | 12 + src/hooks/useNavigator.ts | 56 +++- .../home/components/ChatMainList/index.vue | 13 +- .../home/components/ChatQuickAccess/index.vue | 4 +- .../home/components/MoreService/index.vue | 2 +- .../QuickBookingContentList/index.vue | 16 +- src/pages/login/index.vue | 41 ++- src/router/index.ts | 15 +- src/utils/request.ts | 186 ++++++++++-- 15 files changed, 712 insertions(+), 91 deletions(-) delete mode 100644 src/constants/speech.ts create mode 100644 src/constants/token.test.ts create mode 100644 src/hooks/useNavigator.test.ts diff --git a/components.d.ts b/components.d.ts index a542099..1f374d1 100644 --- a/components.d.ts +++ b/components.d.ts @@ -32,7 +32,6 @@ declare module 'vue' { ModuleTitle: typeof import('./src/components/ModuleTitle/index.vue')['default'] Privacy: typeof import('./src/components/Privacy/index.vue')['default'] Qrcode: typeof import('./src/components/Qrcode/index.vue')['default'] - RecordingWaveBtn: typeof import('./src/components/Speech/RecordingWaveBtn.vue')['default'] RefundPopup: typeof import('./src/components/RefundPopup/index.vue')['default'] ResponseIntro: typeof import('./src/components/ResponseIntro/index.vue')['default'] ResponseWrapper: typeof import('./src/components/ResponseWrapper/index.vue')['default'] @@ -89,7 +88,6 @@ declare global { const ModuleTitle: typeof import('./src/components/ModuleTitle/index.vue')['default'] const Privacy: typeof import('./src/components/Privacy/index.vue')['default'] const Qrcode: typeof import('./src/components/Qrcode/index.vue')['default'] - const RecordingWaveBtn: typeof import('./src/components/Speech/RecordingWaveBtn.vue')['default'] const RefundPopup: typeof import('./src/components/RefundPopup/index.vue')['default'] const ResponseIntro: typeof import('./src/components/ResponseIntro/index.vue')['default'] const ResponseWrapper: typeof import('./src/components/ResponseWrapper/index.vue')['default'] diff --git a/src/api/login.ts b/src/api/login.ts index 0e7c50c..36eac18 100644 --- a/src/api/login.ts +++ b/src/api/login.ts @@ -1,4 +1,6 @@ -import { request } from "../utils/request"; +import { storeAuthTokens } from "@/constants/token"; +import type { OAuthTokenResponse } from "@/constants/token"; +import { request, requestRaw } from "../utils/request"; // 获取oauth token export interface OauthTokenRequest { @@ -28,18 +30,27 @@ export function buildFormUrlEncodedParams( return params; } -export function oauthToken(data: OauthTokenRequest) { +export async function oauthToken(data: OauthTokenRequest): Promise { const params = buildFormUrlEncodedParams(data as Record); - return request({ - url: "/auth/oauth2/token", - method: "post", - data: params, - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: "Basic Y3VzdG9tOmN1c3RvbQ==", + const res = await requestRaw( + { + url: "/auth/oauth2/token", + method: "post", + data: params, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: "Basic Y3VzdG9tOmN1c3RvbQ==", + }, }, + { skipAuth: true }, + ); + + storeAuthTokens(res, { + requireRefreshToken: data.grant_type !== "refresh_token", }); + + return res; } // 发送手机验证码 @@ -50,7 +61,7 @@ export function sendCode(mobile: string) { return request({ url: `/admin/mobile/${encoded}`, method: "get", - }); + }, { skipAuth: true }); } // 检测用户是否绑定手机号 diff --git a/src/constants/speech.ts b/src/constants/speech.ts deleted file mode 100644 index 0fb9e34..0000000 --- a/src/constants/speech.ts +++ /dev/null @@ -1,7 +0,0 @@ -// App 端 yao-asdRealSpeech 使用的阿里云 DashScope 实时语音识别配置。 -// 将 apikey 填成实际的 DashScope API Key 后,App 端语音识别即可发起连接。 -export const appSpeechRecognitionOptions = { - apikey: "sk-2cab1c221b4b47749119d33ab991360a", - language_hints: ["zh"], - saveAudioFile: false, -}; diff --git a/src/constants/token.test.ts b/src/constants/token.test.ts new file mode 100644 index 0000000..78bab6f --- /dev/null +++ b/src/constants/token.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { + clearAuthTokens, + getAccessToken, + getAccessTokenExpiresAt, + getRefreshToken, + isAccessTokenExpired, + storeAuthTokens, +} from "./token.ts"; + +type CookieEntry = { + value: string; +}; + +function installCookieDocument() { + const cookies = new Map(); + const previous = Object.getOwnPropertyDescriptor(globalThis, "document"); + + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + get cookie() { + return Array.from(cookies.entries()) + .map(([name, entry]) => `${name}=${entry.value}`) + .join("; "); + }, + set cookie(rawCookie: string) { + const [nameValue, ...attributes] = rawCookie.split(";").map((item) => item.trim()); + const [name, value] = nameValue.split("="); + const maxAge = attributes.find((item) => item.toLowerCase().startsWith("max-age=")); + + if (maxAge?.toLowerCase() === "max-age=0") { + cookies.delete(name); + return; + } + + cookies.set(name, { value }); + }, + }, + }); + + return () => { + if (previous) { + Object.defineProperty(globalThis, "document", previous); + return; + } + + Reflect.deleteProperty(globalThis, "document"); + }; +} + +describe("auth token cookies", () => { + let restoreDocument: () => void; + + beforeEach(() => { + restoreDocument = installCookieDocument(); + }); + + afterEach(() => { + clearAuthTokens(); + restoreDocument(); + }); + + it("stores oauth tokens in cookies with the access token expiry", () => { + const stored = storeAuthTokens( + { + access_token: "access.token", + refresh_token: "refresh.token", + expires_in: "120", + }, + { nowMs: 1_000, requireRefreshToken: true }, + ); + + assert.equal(stored.accessToken, "access.token"); + assert.equal(stored.refreshToken, "refresh.token"); + assert.equal(stored.expiresAt, 121_000); + assert.equal(getAccessToken(), "access.token"); + assert.equal(getRefreshToken(), "refresh.token"); + assert.equal(getAccessTokenExpiresAt(), 121_000); + assert.equal(isAccessTokenExpired(120_999, 0), false); + assert.equal(isAccessTokenExpired(121_000, 0), true); + }); + + it("uses exp when the oauth response includes an absolute expiry", () => { + const stored = storeAuthTokens( + { + access_token: "access.from.exp", + refresh_token: "refresh.from.exp", + exp: "2025-09-09T06:06:22.155256377Z", + }, + { nowMs: 1_000, requireRefreshToken: true }, + ); + + assert.equal(stored.expiresAt, Date.parse("2025-09-09T06:06:22.155Z")); + }); + + it("keeps the previous refresh token when a refresh response only returns access_token", () => { + storeAuthTokens( + { + access_token: "access.old", + refresh_token: "refresh.keep", + expires_in: "120", + }, + { nowMs: 1_000, requireRefreshToken: true }, + ); + + storeAuthTokens( + { + access_token: "access.new", + expires_in: "240", + }, + { nowMs: 2_000 }, + ); + + assert.equal(getAccessToken(), "access.new"); + assert.equal(getRefreshToken(), "refresh.keep"); + assert.equal(getAccessTokenExpiresAt(), 242_000); + }); +}); diff --git a/src/constants/token.ts b/src/constants/token.ts index 69d0205..3568065 100644 --- a/src/constants/token.ts +++ b/src/constants/token.ts @@ -1,3 +1,272 @@ -export function getAccessToken() { - return localStorage.getItem("accessToken"); +export interface OAuthTokenResponse { + token_type?: string | null; + access_token?: string | null; + refresh_token?: string | null; + expires_in?: string | number | null; + exp?: string | number | null; + refresh_expires_in?: string | number | null; +} + +export interface StoredAuthTokens { + accessToken: string; + refreshToken: string | null; + expiresAt: number | null; +} + +interface StoreAuthTokenOptions { + nowMs?: number; + requireRefreshToken?: boolean; +} + +interface CookieOptions { + expiresAt?: number; + maxAgeSeconds?: number; +} + +const ACCESS_TOKEN_COOKIE = "access_token"; +const REFRESH_TOKEN_COOKIE = "refresh_token"; +const ACCESS_TOKEN_EXPIRES_AT_COOKIE = "access_token_expires_at"; +const REFRESH_TOKEN_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; +const DEFAULT_EXPIRY_SKEW_MS = 60_000; +const LEGACY_LOCAL_STORAGE_KEYS = ["accessToken", "token"]; + +function canUseDocument(): boolean { + return typeof document !== "undefined"; +} + +function canUseLocalStorage(): boolean { + return typeof localStorage !== "undefined"; +} + +function shouldUseSecureCookie(): boolean { + return typeof window !== "undefined" && window.location?.protocol === "https:"; +} + +function encodeCookiePart(value: string): string { + return encodeURIComponent(value); +} + +function decodeCookiePart(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function getCookie(name: string): string | null { + if (!canUseDocument()) { + return null; + } + + const encodedName = `${encodeCookiePart(name)}=`; + const cookies = document.cookie ? document.cookie.split(";") : []; + + for (const cookie of cookies) { + const item = cookie.trim(); + if (item.startsWith(encodedName)) { + return decodeCookiePart(item.slice(encodedName.length)); + } + } + + return null; +} + +function setCookie(name: string, value: string, options: CookieOptions = {}): void { + if (!canUseDocument()) { + return; + } + + const parts = [ + `${encodeCookiePart(name)}=${encodeCookiePart(value)}`, + "Path=/", + "SameSite=Lax", + ]; + + if (options.maxAgeSeconds !== undefined) { + parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`); + } + + if (options.expiresAt !== undefined) { + parts.push(`Expires=${new Date(options.expiresAt).toUTCString()}`); + } + + if (shouldUseSecureCookie()) { + parts.push("Secure"); + } + + document.cookie = parts.join("; "); +} + +function deleteCookie(name: string): void { + if (!canUseDocument()) { + return; + } + + const parts = [ + `${encodeCookiePart(name)}=`, + "Path=/", + "Max-Age=0", + "Expires=Thu, 01 Jan 1970 00:00:00 GMT", + "SameSite=Lax", + ]; + + if (shouldUseSecureCookie()) { + parts.push("Secure"); + } + + document.cookie = parts.join("; "); +} + +function parsePositiveSeconds(value: string | number | null | undefined): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + + const seconds = typeof value === "number" ? value : Number.parseInt(value, 10); + if (!Number.isFinite(seconds) || seconds <= 0) { + return null; + } + + return seconds; +} + +function normalizeIsoFraction(value: string): string { + return value.replace(/\.(\d{3})\d+(Z|[+-]\d{2}:?\d{2})$/, ".$1$2"); +} + +function parseAbsoluteTime(value: string | number | null | undefined): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + + if (typeof value === "number") { + if (!Number.isFinite(value) || value <= 0) { + return null; + } + return value < 1_000_000_000_000 ? value * 1000 : value; + } + + const numericValue = Number(value); + if (Number.isFinite(numericValue) && numericValue > 0) { + return numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue; + } + + const parsed = Date.parse(normalizeIsoFraction(value)); + return Number.isFinite(parsed) ? parsed : null; +} + +export function resolveAccessTokenExpiresAt( + tokenResponse: OAuthTokenResponse, + nowMs = Date.now(), +): number | null { + const absoluteExpiresAt = parseAbsoluteTime(tokenResponse.exp); + if (absoluteExpiresAt !== null) { + return absoluteExpiresAt; + } + + const expiresInSeconds = parsePositiveSeconds(tokenResponse.expires_in); + if (expiresInSeconds !== null) { + return nowMs + expiresInSeconds * 1000; + } + + return null; +} + +export function getAccessToken(): string | null { + return getCookie(ACCESS_TOKEN_COOKIE); +} + +export function getRefreshToken(): string | null { + return getCookie(REFRESH_TOKEN_COOKIE); +} + +export function getAccessTokenExpiresAt(): number | null { + const rawValue = getCookie(ACCESS_TOKEN_EXPIRES_AT_COOKIE); + if (!rawValue) { + return null; + } + + const expiresAt = Number.parseInt(rawValue, 10); + return Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : null; +} + +export function isAccessTokenExpired( + nowMs = Date.now(), + skewMs = DEFAULT_EXPIRY_SKEW_MS, +): boolean { + const expiresAt = getAccessTokenExpiresAt(); + if (expiresAt === null) { + return true; + } + + return nowMs + skewMs >= expiresAt; +} + +function clearLegacyLocalStorageTokens(): void { + if (!canUseLocalStorage()) { + return; + } + + for (const key of LEGACY_LOCAL_STORAGE_KEYS) { + localStorage.removeItem(key); + } +} + +export function clearAuthTokens(): void { + deleteCookie(ACCESS_TOKEN_COOKIE); + deleteCookie(REFRESH_TOKEN_COOKIE); + deleteCookie(ACCESS_TOKEN_EXPIRES_AT_COOKIE); + clearLegacyLocalStorageTokens(); +} + +export function storeAuthTokens( + tokenResponse: OAuthTokenResponse, + options: StoreAuthTokenOptions = {}, +): StoredAuthTokens { + const accessToken = tokenResponse.access_token?.trim(); + const nextRefreshToken = tokenResponse.refresh_token?.trim() || getRefreshToken(); + + if (!accessToken) { + clearAuthTokens(); + throw new Error("Missing access_token"); + } + + if (options.requireRefreshToken && !nextRefreshToken) { + clearAuthTokens(); + throw new Error("Missing refresh_token"); + } + + const nowMs = options.nowMs ?? Date.now(); + const expiresAt = resolveAccessTokenExpiresAt(tokenResponse, nowMs); + + if (expiresAt !== null) { + setCookie(ACCESS_TOKEN_COOKIE, accessToken, { + expiresAt, + maxAgeSeconds: Math.max(0, Math.floor((expiresAt - nowMs) / 1000)), + }); + setCookie(ACCESS_TOKEN_EXPIRES_AT_COOKIE, String(expiresAt), { + expiresAt, + maxAgeSeconds: Math.max(0, Math.floor((expiresAt - nowMs) / 1000)), + }); + } else { + setCookie(ACCESS_TOKEN_COOKIE, accessToken); + deleteCookie(ACCESS_TOKEN_EXPIRES_AT_COOKIE); + } + + if (tokenResponse.refresh_token?.trim()) { + setCookie(REFRESH_TOKEN_COOKIE, tokenResponse.refresh_token.trim(), { + maxAgeSeconds: + parsePositiveSeconds(tokenResponse.refresh_expires_in) ?? + REFRESH_TOKEN_MAX_AGE_SECONDS, + }); + } + + clearLegacyLocalStorageTokens(); + + return { + accessToken, + refreshToken: nextRefreshToken, + expiresAt, + }; } diff --git a/src/hooks/useGoLogin.ts b/src/hooks/useGoLogin.ts index b77fd97..711e826 100644 --- a/src/hooks/useGoLogin.ts +++ b/src/hooks/useGoLogin.ts @@ -1,15 +1,22 @@ +import type { RouteLocationRaw } from "vue-router"; +import { ensureValidAuthToken } from "@/utils/request"; import { goLogin } from "./useNavigator"; -// 检测token -export const checkToken = (): Promise => { - const token = localStorage.getItem("token"); +function stopCurrentAction(): Promise { + return new Promise(() => {}); +} - return new Promise((resolve) => { +export const checkToken = async ( + redirect?: RouteLocationRaw, +): Promise => { + try { + const token = await ensureValidAuthToken(); if (!token) { - goLogin(); - return; + goLogin({ redirect }); + return stopCurrentAction(); } - - resolve(); - }); + } catch { + goLogin({ redirect }); + return stopCurrentAction(); + } }; diff --git a/src/hooks/useNavigator.test.ts b/src/hooks/useNavigator.test.ts new file mode 100644 index 0000000..f7b7785 --- /dev/null +++ b/src/hooks/useNavigator.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { goLogin } from "./useNavigator.ts"; + +describe("useNavigator", () => { + it("can navigate to login outside a component setup context", async () => { + const resolved = await goLogin({ redirect: { name: "quick" } }); + + assert.equal(resolved.name, "login"); + assert.equal(resolved.query.redirect, "/quick"); + }); +}); diff --git a/src/hooks/useNavigator.ts b/src/hooks/useNavigator.ts index a5c5c7d..2f0eea6 100644 --- a/src/hooks/useNavigator.ts +++ b/src/hooks/useNavigator.ts @@ -1,22 +1,52 @@ -import { useRouter } from "vue-router"; +import type { RouteLocationRaw } from "vue-router"; +import { router } from "../router/index.ts"; + +interface LoginNavigateOptions { + redirect?: RouteLocationRaw; +} + +function navigate(location: RouteLocationRaw, replace = false) { + if (typeof window === "undefined") { + return Promise.resolve(router.resolve(location)); + } + + return replace ? router.replace(location) : router.push(location); +} + +function resolveRedirect(redirect?: RouteLocationRaw): string | null { + const target = redirect ?? router.currentRoute.value.fullPath; + const resolved = + typeof target === "string" ? target : router.resolve(target).fullPath; + + if ( + !resolved || + !resolved.startsWith("/") || + resolved.startsWith("//") || + resolved.startsWith("/login") + ) { + return null; + } + + return resolved; +} -// 跳转首页 export const goHome = () => { - const router = useRouter(); - - router.push({ name: "home" }); + return navigate({ name: "home" }); }; -// 跳转登录页 -export const goLogin = () => { - const router = useRouter(); +export const goLogin = (options: LoginNavigateOptions = {}) => { + const redirect = resolveRedirect(options.redirect); + const location = redirect + ? { name: "login", query: { redirect } } + : { name: "login" }; - router.push({ name: "login" }); + if (router.currentRoute.value.name === "login") { + return navigate(location, true); + } + + return navigate(location); }; -// 返回上一页 export const goBack = () => { - const router = useRouter(); - - router.back(); + return router.back(); }; diff --git a/src/pages/home/components/ChatMainList/index.vue b/src/pages/home/components/ChatMainList/index.vue index d53761c..b1dcd37 100644 --- a/src/pages/home/components/ChatMainList/index.vue +++ b/src/pages/home/components/ChatMainList/index.vue @@ -154,6 +154,7 @@ import { IdUtils } from "@/utils/IdUtils"; import { resolveChatSocketUrl } from "@/utils/socketUrl"; import { appendLongTextChunk, createLongTextData } from "@/constants/longTextCard"; import { checkToken } from "@/hooks/useGoLogin"; +import { ensureValidAuthToken } from "@/utils/request"; import { useAppStore } from "@/store"; import { getAccessToken } from "@/constants/token"; import Welcome from "../Welcome/index.vue"; @@ -470,9 +471,9 @@ const addNoticeListener = () => { }; // token存在,初始化数据 -const initHandler = () => { +const initHandler = async () => { console.log("initHandler"); - const token = getAccessToken(); + const token = await ensureValidAuthToken().catch(() => null); if (!token) return; loadRecentConversation(); @@ -546,8 +547,12 @@ const initWebSocket = async () => { } // 使用配置的WebSocket服务器地址 - // const token = getAccessToken(); - const token = import.meta.env.VITE_TOKEN; + const token = getAccessToken(); + if (!token) { + isInitializing = false; + pendingInitPromise = null; + return false; + } const resolvedWsUrl = resolveChatSocketUrl(appStore.serverConfig.wssUrl); const wsUrl = `${resolvedWsUrl}?access_token=${token}`; diff --git a/src/pages/home/components/ChatQuickAccess/index.vue b/src/pages/home/components/ChatQuickAccess/index.vue index e8d2242..e90156f 100644 --- a/src/pages/home/components/ChatQuickAccess/index.vue +++ b/src/pages/home/components/ChatQuickAccess/index.vue @@ -56,7 +56,7 @@ const sendReply = (item) => { // 快速预定 if (item.type === Command.quickBooking) { - checkToken().then(() => { + checkToken({ name: "quick" }).then(() => { router.push({ name: "quick" }); }); return; @@ -64,7 +64,7 @@ const sendReply = (item) => { // 我的订单 if (item.type === Command.myOrder) { - checkToken().then(() => { + checkToken({ name: "orderList" }).then(() => { router.push({ name: "orderList" }); }); return; diff --git a/src/pages/home/components/MoreService/index.vue b/src/pages/home/components/MoreService/index.vue index 6edfd2f..b98371f 100644 --- a/src/pages/home/components/MoreService/index.vue +++ b/src/pages/home/components/MoreService/index.vue @@ -101,7 +101,7 @@ const handleClick = (item) => { close(); if (item.path) { - checkToken().then(() => { + checkToken({ path: item.path }).then(() => { router.push({ path: item.path }); }); diff --git a/src/pages/home/components/QuickBookingContentList/index.vue b/src/pages/home/components/QuickBookingContentList/index.vue index c814d54..fe55440 100644 --- a/src/pages/home/components/QuickBookingContentList/index.vue +++ b/src/pages/home/components/QuickBookingContentList/index.vue @@ -59,13 +59,15 @@ const router = useRouter() // 去下单 const placeOrderHandle = (item) => { - checkToken().then(() => { - router.push({ - name: 'goods', - query: { - commodityId: item.commodityId, - } - }) + const target = { + name: 'goods', + query: { + commodityId: item.commodityId, + } + }; + + checkToken(target).then(() => { + router.push(target) }); }; diff --git a/src/pages/login/index.vue b/src/pages/login/index.vue index dd3beda..930c0c0 100644 --- a/src/pages/login/index.vue +++ b/src/pages/login/index.vue @@ -77,17 +77,40 @@