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
This commit is contained in:
2
components.d.ts
vendored
2
components.d.ts
vendored
@@ -32,7 +32,6 @@ declare module 'vue' {
|
|||||||
ModuleTitle: typeof import('./src/components/ModuleTitle/index.vue')['default']
|
ModuleTitle: typeof import('./src/components/ModuleTitle/index.vue')['default']
|
||||||
Privacy: typeof import('./src/components/Privacy/index.vue')['default']
|
Privacy: typeof import('./src/components/Privacy/index.vue')['default']
|
||||||
Qrcode: typeof import('./src/components/Qrcode/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']
|
RefundPopup: typeof import('./src/components/RefundPopup/index.vue')['default']
|
||||||
ResponseIntro: typeof import('./src/components/ResponseIntro/index.vue')['default']
|
ResponseIntro: typeof import('./src/components/ResponseIntro/index.vue')['default']
|
||||||
ResponseWrapper: typeof import('./src/components/ResponseWrapper/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 ModuleTitle: typeof import('./src/components/ModuleTitle/index.vue')['default']
|
||||||
const Privacy: typeof import('./src/components/Privacy/index.vue')['default']
|
const Privacy: typeof import('./src/components/Privacy/index.vue')['default']
|
||||||
const Qrcode: typeof import('./src/components/Qrcode/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 RefundPopup: typeof import('./src/components/RefundPopup/index.vue')['default']
|
||||||
const ResponseIntro: typeof import('./src/components/ResponseIntro/index.vue')['default']
|
const ResponseIntro: typeof import('./src/components/ResponseIntro/index.vue')['default']
|
||||||
const ResponseWrapper: typeof import('./src/components/ResponseWrapper/index.vue')['default']
|
const ResponseWrapper: typeof import('./src/components/ResponseWrapper/index.vue')['default']
|
||||||
|
|||||||
@@ -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
|
// 获取oauth token
|
||||||
export interface OauthTokenRequest {
|
export interface OauthTokenRequest {
|
||||||
@@ -28,10 +30,11 @@ export function buildFormUrlEncodedParams(
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function oauthToken(data: OauthTokenRequest) {
|
export async function oauthToken(data: OauthTokenRequest): Promise<OAuthTokenResponse> {
|
||||||
const params = buildFormUrlEncodedParams(data as Record<string, unknown>);
|
const params = buildFormUrlEncodedParams(data as Record<string, unknown>);
|
||||||
|
|
||||||
return request({
|
const res = await requestRaw<OAuthTokenResponse>(
|
||||||
|
{
|
||||||
url: "/auth/oauth2/token",
|
url: "/auth/oauth2/token",
|
||||||
method: "post",
|
method: "post",
|
||||||
data: params,
|
data: params,
|
||||||
@@ -39,7 +42,15 @@ export function oauthToken(data: OauthTokenRequest) {
|
|||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
Authorization: "Basic Y3VzdG9tOmN1c3RvbQ==",
|
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({
|
return request({
|
||||||
url: `/admin/mobile/${encoded}`,
|
url: `/admin/mobile/${encoded}`,
|
||||||
method: "get",
|
method: "get",
|
||||||
});
|
}, { skipAuth: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检测用户是否绑定手机号
|
// 检测用户是否绑定手机号
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
// App 端 yao-asdRealSpeech 使用的阿里云 DashScope 实时语音识别配置。
|
|
||||||
// 将 apikey 填成实际的 DashScope API Key 后,App 端语音识别即可发起连接。
|
|
||||||
export const appSpeechRecognitionOptions = {
|
|
||||||
apikey: "sk-2cab1c221b4b47749119d33ab991360a",
|
|
||||||
language_hints: ["zh"],
|
|
||||||
saveAudioFile: false,
|
|
||||||
};
|
|
||||||
120
src/constants/token.test.ts
Normal file
120
src/constants/token.test.ts
Normal file
@@ -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<string, CookieEntry>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,272 @@
|
|||||||
export function getAccessToken() {
|
export interface OAuthTokenResponse {
|
||||||
return localStorage.getItem("accessToken");
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
|
import type { RouteLocationRaw } from "vue-router";
|
||||||
|
import { ensureValidAuthToken } from "@/utils/request";
|
||||||
import { goLogin } from "./useNavigator";
|
import { goLogin } from "./useNavigator";
|
||||||
|
|
||||||
// 检测token
|
function stopCurrentAction(): Promise<void> {
|
||||||
export const checkToken = (): Promise<void> => {
|
return new Promise(() => {});
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!token) {
|
|
||||||
goLogin();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve();
|
export const checkToken = async (
|
||||||
});
|
redirect?: RouteLocationRaw,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const token = await ensureValidAuthToken();
|
||||||
|
if (!token) {
|
||||||
|
goLogin({ redirect });
|
||||||
|
return stopCurrentAction();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
goLogin({ redirect });
|
||||||
|
return stopCurrentAction();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
12
src/hooks/useNavigator.test.ts
Normal file
12
src/hooks/useNavigator.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = () => {
|
export const goHome = () => {
|
||||||
const router = useRouter();
|
return navigate({ name: "home" });
|
||||||
|
|
||||||
router.push({ name: "home" });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 跳转登录页
|
export const goLogin = (options: LoginNavigateOptions = {}) => {
|
||||||
export const goLogin = () => {
|
const redirect = resolveRedirect(options.redirect);
|
||||||
const router = useRouter();
|
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 = () => {
|
export const goBack = () => {
|
||||||
const router = useRouter();
|
return router.back();
|
||||||
|
|
||||||
router.back();
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ import { IdUtils } from "@/utils/IdUtils";
|
|||||||
import { resolveChatSocketUrl } from "@/utils/socketUrl";
|
import { resolveChatSocketUrl } from "@/utils/socketUrl";
|
||||||
import { appendLongTextChunk, createLongTextData } from "@/constants/longTextCard";
|
import { appendLongTextChunk, createLongTextData } from "@/constants/longTextCard";
|
||||||
import { checkToken } from "@/hooks/useGoLogin";
|
import { checkToken } from "@/hooks/useGoLogin";
|
||||||
|
import { ensureValidAuthToken } from "@/utils/request";
|
||||||
import { useAppStore } from "@/store";
|
import { useAppStore } from "@/store";
|
||||||
import { getAccessToken } from "@/constants/token";
|
import { getAccessToken } from "@/constants/token";
|
||||||
import Welcome from "../Welcome/index.vue";
|
import Welcome from "../Welcome/index.vue";
|
||||||
@@ -470,9 +471,9 @@ const addNoticeListener = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// token存在,初始化数据
|
// token存在,初始化数据
|
||||||
const initHandler = () => {
|
const initHandler = async () => {
|
||||||
console.log("initHandler");
|
console.log("initHandler");
|
||||||
const token = getAccessToken();
|
const token = await ensureValidAuthToken().catch(() => null);
|
||||||
|
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
loadRecentConversation();
|
loadRecentConversation();
|
||||||
@@ -546,8 +547,12 @@ const initWebSocket = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用配置的WebSocket服务器地址
|
// 使用配置的WebSocket服务器地址
|
||||||
// const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
const token = import.meta.env.VITE_TOKEN;
|
if (!token) {
|
||||||
|
isInitializing = false;
|
||||||
|
pendingInitPromise = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const resolvedWsUrl = resolveChatSocketUrl(appStore.serverConfig.wssUrl);
|
const resolvedWsUrl = resolveChatSocketUrl(appStore.serverConfig.wssUrl);
|
||||||
const wsUrl = `${resolvedWsUrl}?access_token=${token}`;
|
const wsUrl = `${resolvedWsUrl}?access_token=${token}`;
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const sendReply = (item) => {
|
|||||||
|
|
||||||
// 快速预定
|
// 快速预定
|
||||||
if (item.type === Command.quickBooking) {
|
if (item.type === Command.quickBooking) {
|
||||||
checkToken().then(() => {
|
checkToken({ name: "quick" }).then(() => {
|
||||||
router.push({ name: "quick" });
|
router.push({ name: "quick" });
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -64,7 +64,7 @@ const sendReply = (item) => {
|
|||||||
|
|
||||||
// 我的订单
|
// 我的订单
|
||||||
if (item.type === Command.myOrder) {
|
if (item.type === Command.myOrder) {
|
||||||
checkToken().then(() => {
|
checkToken({ name: "orderList" }).then(() => {
|
||||||
router.push({ name: "orderList" });
|
router.push({ name: "orderList" });
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ const handleClick = (item) => {
|
|||||||
close();
|
close();
|
||||||
|
|
||||||
if (item.path) {
|
if (item.path) {
|
||||||
checkToken().then(() => {
|
checkToken({ path: item.path }).then(() => {
|
||||||
router.push({ path: item.path });
|
router.push({ path: item.path });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -59,13 +59,15 @@ const router = useRouter()
|
|||||||
|
|
||||||
// 去下单
|
// 去下单
|
||||||
const placeOrderHandle = (item) => {
|
const placeOrderHandle = (item) => {
|
||||||
checkToken().then(() => {
|
const target = {
|
||||||
router.push({
|
|
||||||
name: 'goods',
|
name: 'goods',
|
||||||
query: {
|
query: {
|
||||||
commodityId: item.commodityId,
|
commodityId: item.commodityId,
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
|
|
||||||
|
checkToken(target).then(() => {
|
||||||
|
router.push(target)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -77,17 +77,40 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref } from "vue";
|
import { computed, nextTick, onMounted, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { showToast } from "vant";
|
import { showToast } from "vant";
|
||||||
import { oauthToken, sendCode } from "@/api/login";
|
import { oauthToken, sendCode } from "@/api/login";
|
||||||
|
import { NOTICE_EVENT_LOGIN_SUCCESS } from "@/constants/constant";
|
||||||
import { COUNTRY_CALLING_CODES, findCountryCallingCode } from "@/constants/countryCallingCodes";
|
import { COUNTRY_CALLING_CODES, findCountryCallingCode } from "@/constants/countryCallingCodes";
|
||||||
import { getCurrentLocale, setLocale } from "@/i18n";
|
import { getCurrentLocale, setLocale } from "@/i18n";
|
||||||
|
import { emitter } from "@/utils/events";
|
||||||
import { Globe } from '@lucide/vue'
|
import { Globe } from '@lucide/vue'
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
function resolveLoginRedirect(): string | null {
|
||||||
|
const redirect = route.query.redirect;
|
||||||
|
const value = Array.isArray(redirect) ? redirect[0] : redirect;
|
||||||
|
|
||||||
|
if (!value || !value.startsWith("/") || value.startsWith("//")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith("/login")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateAfterLogin() {
|
||||||
|
const redirect = resolveLoginRedirect();
|
||||||
|
return router.replace(redirect ?? { name: "home" });
|
||||||
|
}
|
||||||
|
|
||||||
const googleButtonEl = ref<HTMLElement | null>(null);
|
const googleButtonEl = ref<HTMLElement | null>(null);
|
||||||
const googleInited = ref(false);
|
const googleInited = ref(false);
|
||||||
const googleButtonRendered = ref(false);
|
const googleButtonRendered = ref(false);
|
||||||
@@ -231,7 +254,8 @@ async function handlePhoneLogin() {
|
|||||||
code: codeValue,
|
code: codeValue,
|
||||||
grant_type: "mobile",
|
grant_type: "mobile",
|
||||||
});
|
});
|
||||||
router.go(-1);
|
emitter.emit(NOTICE_EVENT_LOGIN_SUCCESS);
|
||||||
|
await navigateAfterLogin();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
showToast(t("common.errors.network"));
|
showToast(t("common.errors.network"));
|
||||||
@@ -303,21 +327,14 @@ onMounted(() => {
|
|||||||
"237078935964-e2pioe5hlfbcd4gaam4m9sn859iihdb9.apps.googleusercontent.com",
|
"237078935964-e2pioe5hlfbcd4gaam4m9sn859iihdb9.apps.googleusercontent.com",
|
||||||
ux_mode: "popup",
|
ux_mode: "popup",
|
||||||
callback: (response: any) => {
|
callback: (response: any) => {
|
||||||
// 登录成功后,谷歌会直接把凭证传进这个函数
|
|
||||||
console.log("谷歌登录成功,收到凭证:", response);
|
|
||||||
|
|
||||||
// 这个 credential 就是 JWT (ID Token)
|
// 这个 credential 就是 JWT (ID Token)
|
||||||
const idToken = response.credential;
|
const idToken = response.credential;
|
||||||
|
|
||||||
if (idToken) {
|
if (idToken) {
|
||||||
// 【后续业务】:你可以将这串 idToken 通过 axios 发送给你自己的后端接口
|
|
||||||
// 或者是直接利用第三方平台(如 Firebase / Supabase)完成纯前端鉴权
|
|
||||||
console.log("拿到的 ID Token 是:", idToken);
|
|
||||||
oauthToken({ openIdCode: [idToken], grant_type: 'google' })
|
oauthToken({ openIdCode: [idToken], grant_type: 'google' })
|
||||||
.then((res) => {
|
.then(() => {
|
||||||
console.log("获取到的 oauth token:", res)
|
emitter.emit(NOTICE_EVENT_LOGIN_SUCCESS);
|
||||||
|
return navigateAfterLogin()
|
||||||
router.go(-1)
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createMemoryHistory, createRouter, createWebHistory } from "vue-router";
|
||||||
import type { RouteRecordRaw } from "vue-router";
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
|
||||||
export const routes = [
|
export const routes = [
|
||||||
@@ -54,8 +54,19 @@ export const routes = [
|
|||||||
},
|
},
|
||||||
] satisfies RouteRecordRaw[];
|
] satisfies RouteRecordRaw[];
|
||||||
|
|
||||||
|
function resolveRouterBase(): string {
|
||||||
|
return import.meta.env?.BASE_URL ?? "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRouterHistory() {
|
||||||
|
const base = resolveRouterBase();
|
||||||
|
return typeof window === "undefined"
|
||||||
|
? createMemoryHistory(base)
|
||||||
|
: createWebHistory(base);
|
||||||
|
}
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createRouterHistory(),
|
||||||
routes,
|
routes,
|
||||||
scrollBehavior() {
|
scrollBehavior() {
|
||||||
return { top: 0 };
|
return { top: 0 };
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import type { AxiosError, AxiosRequestConfig } from "axios";
|
import type { AxiosError, AxiosRequestConfig } from "axios";
|
||||||
|
import {
|
||||||
|
clearAuthTokens,
|
||||||
|
getAccessToken,
|
||||||
|
getRefreshToken,
|
||||||
|
isAccessTokenExpired,
|
||||||
|
storeAuthTokens,
|
||||||
|
} from "@/constants/token";
|
||||||
|
import type { OAuthTokenResponse } from "@/constants/token";
|
||||||
import { readStoredLocale } from "@/i18n/storage";
|
import { readStoredLocale } from "@/i18n/storage";
|
||||||
import { defaultLocale } from "@/i18n/types";
|
import { defaultLocale } from "@/i18n/types";
|
||||||
import type {
|
import type {
|
||||||
@@ -33,14 +41,20 @@ const http = axios.create({
|
|||||||
timeout: resolveTimeout(),
|
timeout: resolveTimeout(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const OAUTH_TOKEN_URL = "/auth/oauth2/token";
|
||||||
|
const OAUTH_BASIC_AUTHORIZATION = "Basic Y3VzdG9tOmN1c3RvbQ==";
|
||||||
|
const AUTH_REFRESH_ERROR_STATUSES = new Set([400, 401, 403]);
|
||||||
|
|
||||||
let context: RequestContext = {
|
let context: RequestContext = {
|
||||||
token: null,
|
token: getAccessToken(),
|
||||||
clientId: import.meta.env.VITE_CLIENT_ID,
|
clientId: import.meta.env.VITE_CLIENT_ID ?? null,
|
||||||
latitude: null,
|
latitude: null,
|
||||||
longitude: null,
|
longitude: null,
|
||||||
language: null,
|
language: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let refreshAccessTokenPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
export function setAuthToken(token: string | null): void {
|
export function setAuthToken(token: string | null): void {
|
||||||
context = { ...context, token };
|
context = { ...context, token };
|
||||||
}
|
}
|
||||||
@@ -96,11 +110,9 @@ function resolveRequestLanguage(): string | null {
|
|||||||
function buildContextHeaders(options?: RequestOptions): Record<string, string> {
|
function buildContextHeaders(options?: RequestOptions): Record<string, string> {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
// 临时token,后续需要从登录获取token
|
const token = context.token ?? getAccessToken();
|
||||||
setAuthToken(import.meta.env.VITE_TOKEN);
|
if (!options?.skipAuth && token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
if (!options?.skipAuth && context.token) {
|
|
||||||
headers.Authorization = `Bearer ${context.token}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.clientId) {
|
if (context.clientId) {
|
||||||
@@ -123,6 +135,127 @@ function buildContextHeaders(options?: RequestOptions): Record<string, string> {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRefreshTokenParams(refreshToken: string): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append("grant_type", "refresh_token");
|
||||||
|
params.append("refresh_token", refreshToken);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken(): Promise<string> {
|
||||||
|
if (refreshAccessTokenPromise) {
|
||||||
|
return refreshAccessTokenPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshAccessTokenPromise = (async () => {
|
||||||
|
const refreshToken = getRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
clearAuthTokens();
|
||||||
|
setAuthToken(null);
|
||||||
|
throw new RequestError({
|
||||||
|
kind: "http",
|
||||||
|
httpStatus: 401,
|
||||||
|
message: "Missing refresh token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await http.request<OAuthTokenResponse>({
|
||||||
|
url: OAUTH_TOKEN_URL,
|
||||||
|
method: "post",
|
||||||
|
data: buildRefreshTokenParams(refreshToken),
|
||||||
|
headers: {
|
||||||
|
...buildContextHeaders({ skipAuth: true }),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Authorization: OAUTH_BASIC_AUTHORIZATION,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const tokens = storeAuthTokens(res.data);
|
||||||
|
setAuthToken(tokens.accessToken);
|
||||||
|
return tokens.accessToken;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (
|
||||||
|
axios.isAxiosError(e) &&
|
||||||
|
e.response &&
|
||||||
|
AUTH_REFRESH_ERROR_STATUSES.has(e.response.status)
|
||||||
|
) {
|
||||||
|
clearAuthTokens();
|
||||||
|
setAuthToken(null);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
refreshAccessTokenPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return refreshAccessTokenPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareAuthToken(options: RequestOptions): Promise<void> {
|
||||||
|
if (options.skipAuth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = getAccessToken();
|
||||||
|
if (!accessToken) {
|
||||||
|
setAuthToken(null);
|
||||||
|
if (getRefreshToken()) {
|
||||||
|
await refreshAccessToken();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthToken(accessToken);
|
||||||
|
if (isAccessTokenExpired()) {
|
||||||
|
await refreshAccessToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetryUnauthorized(
|
||||||
|
error: unknown,
|
||||||
|
options: RequestOptions,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
!options.skipAuth &&
|
||||||
|
axios.isAxiosError(error) &&
|
||||||
|
error.response?.status === 401 &&
|
||||||
|
Boolean(getRefreshToken())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendHttpRequest<T>(
|
||||||
|
config: AxiosRequestConfig,
|
||||||
|
options: RequestOptions,
|
||||||
|
allowAuthRetry = true,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await prepareAuthToken(options);
|
||||||
|
const ctxHeaders = buildContextHeaders(options);
|
||||||
|
const mergedHeaders: Record<string, string> = {
|
||||||
|
...ctxHeaders,
|
||||||
|
...((config.headers ?? {}) as Record<string, string>),
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return await http.request<T>({
|
||||||
|
...config,
|
||||||
|
signal: options.signal ?? config.signal,
|
||||||
|
headers: mergedHeaders,
|
||||||
|
});
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (allowAuthRetry && shouldRetryUnauthorized(e, options)) {
|
||||||
|
await refreshAccessToken();
|
||||||
|
return sendHttpRequest<T>(config, options, false);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureValidAuthToken(): Promise<string | null> {
|
||||||
|
await prepareAuthToken({});
|
||||||
|
return context.token ?? getAccessToken();
|
||||||
|
}
|
||||||
|
|
||||||
function isApiResponse(value: unknown): value is ApiResponse<unknown> {
|
function isApiResponse(value: unknown): value is ApiResponse<unknown> {
|
||||||
if (!value || typeof value !== "object") {
|
if (!value || typeof value !== "object") {
|
||||||
return false;
|
return false;
|
||||||
@@ -154,19 +287,7 @@ export async function request<T>(
|
|||||||
options: RequestOptions = {},
|
options: RequestOptions = {},
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
try {
|
try {
|
||||||
const ctxHeaders = buildContextHeaders(options);
|
const res = await sendHttpRequest<ApiResponse<T>>(config, options);
|
||||||
const mergedHeaders: Record<string, string> = {
|
|
||||||
...ctxHeaders,
|
|
||||||
...((config.headers ?? {}) as Record<string, string>),
|
|
||||||
...(options.headers ?? {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await http.request<ApiResponse<T>>({
|
|
||||||
...config,
|
|
||||||
signal: options.signal ?? config.signal,
|
|
||||||
headers: mergedHeaders,
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = res.data as unknown;
|
const body = res.data as unknown;
|
||||||
if (!isApiResponse(body)) {
|
if (!isApiResponse(body)) {
|
||||||
throw new RequestError({
|
throw new RequestError({
|
||||||
@@ -204,6 +325,31 @@ export async function request<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function requestRaw<T>(
|
||||||
|
config: AxiosRequestConfig,
|
||||||
|
options: RequestOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const res = await sendHttpRequest<T>(config, options);
|
||||||
|
return res.data;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof RequestError) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axios.isAxiosError(e)) {
|
||||||
|
throw normalizeAxiosError(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
|
throw new RequestError({
|
||||||
|
kind: "unknown",
|
||||||
|
message: err.message || "Unknown Error",
|
||||||
|
response: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function requestData<T>(
|
export async function requestData<T>(
|
||||||
config: AxiosRequestConfig,
|
config: AxiosRequestConfig,
|
||||||
options: RequestOptions = {},
|
options: RequestOptions = {},
|
||||||
|
|||||||
Reference in New Issue
Block a user