fix:无法设置token的问题
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 认证刷新拦截器
|
||||
*
|
||||
* 处理 401 错误,自动刷新 token 并重试原请求。
|
||||
* 原始 Dart: lib/core/net/interceptor/auth_interceptor.dart
|
||||
*/
|
||||
import type { FetchHook } from "ofetch";
|
||||
import { ApiPath } from "../../../data/services/api/api_path";
|
||||
import { ApiError, ErrorCode } from "../../../data/services/api/api_result";
|
||||
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||
import { deviceIdentifier } from "@/utils/device_identifier";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
/**
|
||||
* 不需要 auth token 刷新的路径
|
||||
* (这些路径返回 401 时不触发刷新)
|
||||
*/
|
||||
const NO_AUTH_REFRESH_PATHS: readonly string[] = [
|
||||
ApiPath.sendCode,
|
||||
ApiPath.emailLogin,
|
||||
ApiPath.register,
|
||||
ApiPath.guestLogin,
|
||||
ApiPath.refresh,
|
||||
];
|
||||
|
||||
/**
|
||||
* 是否正在刷新 token(防止并发刷新)
|
||||
*/
|
||||
let isRefreshing = false;
|
||||
|
||||
/**
|
||||
* 等待中的刷新 Promise
|
||||
*/
|
||||
let refreshPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* 获取当前可用的 auth token(登录 token 优先,其次游客 token)
|
||||
*/
|
||||
async function getAuthTokenAsync(): Promise<string | null> {
|
||||
const storage = AuthStorage.getInstance();
|
||||
const loginResult = await storage.getLoginToken();
|
||||
if (Result.isOk(loginResult) && loginResult.data && loginResult.data.length > 0) {
|
||||
return loginResult.data;
|
||||
}
|
||||
const guestResult = await storage.getGuestToken();
|
||||
if (Result.isOk(guestResult) && guestResult.data && guestResult.data.length > 0) {
|
||||
return guestResult.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际刷新 token(被拦截器调用)
|
||||
*/
|
||||
async function refreshToken(
|
||||
isGuestMode: boolean,
|
||||
baseUrl: string
|
||||
): Promise<void> {
|
||||
if (isGuestMode) {
|
||||
await refreshGuestToken(baseUrl);
|
||||
} else {
|
||||
await refreshLoginToken(baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新登录 token
|
||||
*/
|
||||
async function refreshLoginToken(baseUrl: string): Promise<void> {
|
||||
const storage = AuthStorage.getInstance();
|
||||
const refreshResult = await storage.getRefreshToken();
|
||||
if (!Result.isOk(refreshResult) || !refreshResult.data) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}${ApiPath.refresh}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken: refreshResult.data }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
success?: boolean;
|
||||
data?: { token?: string; refreshToken?: string };
|
||||
};
|
||||
|
||||
if (json.success && json.data) {
|
||||
if (json.data.token) await storage.setLoginToken(json.data.token);
|
||||
if (json.data.refreshToken) await storage.setRefreshToken(json.data.refreshToken);
|
||||
} else {
|
||||
throw new Error("Refresh response invalid");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新游客 token
|
||||
*/
|
||||
async function refreshGuestToken(baseUrl: string): Promise<void> {
|
||||
const storage = AuthStorage.getInstance();
|
||||
const deviceId = await deviceIdentifier.getDeviceId();
|
||||
|
||||
const response = await fetch(`${baseUrl}${ApiPath.guestLogin}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ deviceId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Guest refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
success?: boolean;
|
||||
data?: { token?: string; deviceId?: string };
|
||||
};
|
||||
|
||||
if (json.success && json.data) {
|
||||
if (json.data.token) await storage.setGuestToken(json.data.token);
|
||||
if (json.data.deviceId) await storage.setDeviceId(json.data.deviceId);
|
||||
} else {
|
||||
throw new Error("Guest refresh response invalid");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 401 → 刷新 token 并重试
|
||||
*/
|
||||
export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
|
||||
const status = ctx.response?.status;
|
||||
|
||||
// 只处理 401
|
||||
if (status !== ErrorCode.httpUnauthorized) return;
|
||||
|
||||
// 跳过不需要刷新的路径
|
||||
const requestUrl =
|
||||
ctx.request instanceof Request ? ctx.request.url : ctx.request;
|
||||
const path = new URL(requestUrl).pathname;
|
||||
if (NO_AUTH_REFRESH_PATHS.some((p) => path.includes(p))) return;
|
||||
|
||||
// 已登录则刷新登录 token,否则刷新游客 token
|
||||
const isGuestMode = !AuthStorage.getInstance().hasLoginToken();
|
||||
|
||||
// 并发刷新:等待或启动
|
||||
if (isRefreshing && refreshPromise) {
|
||||
await refreshPromise;
|
||||
} else {
|
||||
isRefreshing = true;
|
||||
const baseUrl = new URL(requestUrl).origin;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
await refreshToken(isGuestMode, baseUrl);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
await refreshPromise;
|
||||
}
|
||||
|
||||
// 刷新失败 → 清除数据并抛出
|
||||
const newToken = await getAuthTokenAsync();
|
||||
if (!newToken) {
|
||||
AuthStorage.getInstance().clearAuthData();
|
||||
throw new ApiError(
|
||||
ErrorCode.unknownError,
|
||||
"Token refresh failed",
|
||||
status
|
||||
);
|
||||
}
|
||||
|
||||
// 刷新成功:更新请求头的 token
|
||||
if (ctx.request instanceof Request) {
|
||||
ctx.request.headers.set("Authorization", `Bearer ${newToken}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 兼容旧接口的 FetchHooks 形式
|
||||
*/
|
||||
export const authRefreshInterceptor = {
|
||||
onResponseError: onResponseErrorAuthRefresh,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 日志拦截器
|
||||
*
|
||||
* 在开发模式下记录请求/响应/错误日志。
|
||||
* 原始 Dart: lib/core/net/interceptor/app_interceptor.dart 中的日志部分。
|
||||
*/
|
||||
import type { FetchHook } from "ofetch";
|
||||
|
||||
const TAG = "[API]";
|
||||
|
||||
function isDev(): boolean {
|
||||
return process.env.NODE_ENV !== "production";
|
||||
}
|
||||
|
||||
function getRequestUrl(request: Request | string): string {
|
||||
return typeof request === "string" ? request : request.url;
|
||||
}
|
||||
|
||||
function getRequestMethod(
|
||||
request: Request | string,
|
||||
options: { method?: string }
|
||||
): string {
|
||||
return request instanceof Request ? request.method : options.method ?? "GET";
|
||||
}
|
||||
|
||||
function formatBody(body: unknown): string {
|
||||
if (body === undefined || body === null) return "";
|
||||
try {
|
||||
if (body instanceof FormData) {
|
||||
const entries: string[] = [];
|
||||
body.forEach((value, key) => {
|
||||
if (value instanceof File) {
|
||||
entries.push(`${key}=File(${value.name})`);
|
||||
} else {
|
||||
entries.push(`${key}=${String(value)}`);
|
||||
}
|
||||
});
|
||||
return `FormData{${entries.join(", ")}}`;
|
||||
}
|
||||
return JSON.stringify(body);
|
||||
} catch {
|
||||
return String(body);
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestLogging: FetchHook = async (ctx) => {
|
||||
if (!isDev()) return;
|
||||
const method = getRequestMethod(ctx.request, ctx.options);
|
||||
const url = getRequestUrl(ctx.request);
|
||||
console.log(
|
||||
`${TAG} → ${method} ${url}`,
|
||||
ctx.options.body !== undefined ? formatBody(ctx.options.body) : ""
|
||||
);
|
||||
};
|
||||
|
||||
export const onResponseLogging: FetchHook = async (ctx) => {
|
||||
if (!isDev() || !ctx.response) return;
|
||||
const method = getRequestMethod(ctx.request, ctx.options);
|
||||
const url = getRequestUrl(ctx.request);
|
||||
console.log(
|
||||
`${TAG} ← ${ctx.response.status} ${method} ${url}`,
|
||||
formatBody(ctx.response._data)
|
||||
);
|
||||
};
|
||||
|
||||
export const onErrorLogging: FetchHook = async (ctx) => {
|
||||
if (!isDev()) return;
|
||||
const status = ctx.response?.status ?? "N/A";
|
||||
const method = getRequestMethod(ctx.request, ctx.options);
|
||||
const url = getRequestUrl(ctx.request);
|
||||
console.error(
|
||||
`${TAG} ✕ ${status} ${method} ${url}`,
|
||||
ctx.error?.message ?? "Unknown error"
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 兼容旧接口的 FetchHooks 形式(保留以备兼容)
|
||||
*/
|
||||
export const loggingInterceptor = {
|
||||
onRequest: onRequestLogging,
|
||||
onResponse: onResponseLogging,
|
||||
onResponseError: onErrorLogging,
|
||||
onRequestError: onErrorLogging,
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Token 拦截器
|
||||
*
|
||||
* 在请求头中注入 Authorization: Bearer <token>。
|
||||
* 跳过不需要 token 的路径(登录、注册、发送验证码、刷新等)。
|
||||
* 原始 Dart: lib/core/net/interceptor/token_interceptor.dart + token_paths.dart
|
||||
*/
|
||||
import type { FetchHook } from "ofetch";
|
||||
import { ApiPath } from "../../../data/services/api/api_path";
|
||||
import { AuthStorage } from "../../../data/storage/auth/auth_storage";
|
||||
|
||||
/**
|
||||
* 不需要 token 的路径
|
||||
*/
|
||||
const NO_TOKEN_PATHS: readonly string[] = [
|
||||
ApiPath.sendCode,
|
||||
ApiPath.emailLogin,
|
||||
ApiPath.register,
|
||||
ApiPath.guestLogin,
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取请求路径
|
||||
*/
|
||||
function getRequestPath(request: Request | string): string {
|
||||
if (typeof request === "string") {
|
||||
try {
|
||||
return new URL(request).pathname;
|
||||
} catch {
|
||||
return request;
|
||||
}
|
||||
}
|
||||
return new URL(request.url).pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径是否需要 token
|
||||
*/
|
||||
function needsToken(path: string): boolean {
|
||||
return !NO_TOKEN_PATHS.some((noTokenPath) => path.includes(noTokenPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前可用的 auth token(登录 token 优先,其次游客 token)
|
||||
*/
|
||||
async function getAuthToken(): Promise<string | null> {
|
||||
const storage = AuthStorage.getInstance();
|
||||
const loginR = await storage.getLoginToken();
|
||||
if (loginR.success && loginR.data && loginR.data.length > 0) {
|
||||
console.log("[token-interceptor] getAuthToken: using LOGIN token", {
|
||||
length: loginR.data.length,
|
||||
prefix: loginR.data.slice(0, 8),
|
||||
});
|
||||
return loginR.data;
|
||||
}
|
||||
console.log("[token-interceptor] getAuthToken: no login token", {
|
||||
success: loginR.success,
|
||||
hasData: loginR.success ? !!loginR.data : false,
|
||||
});
|
||||
|
||||
const guestR = await storage.getGuestToken();
|
||||
if (guestR.success && guestR.data && guestR.data.length > 0) {
|
||||
console.log(
|
||||
"[token-interceptor] getAuthToken: using GUEST token (login priority, no login available)",
|
||||
{
|
||||
length: guestR.data.length,
|
||||
prefix: guestR.data.slice(0, 8),
|
||||
},
|
||||
);
|
||||
return guestR.data;
|
||||
}
|
||||
console.warn("[token-interceptor] getAuthToken: NO token available (will skip Authorization)", {
|
||||
loginAttempted: true,
|
||||
guestAttempted: true,
|
||||
loginSuccess: loginR.success,
|
||||
guestSuccess: guestR.success,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export const onRequestToken: FetchHook = async (ctx) => {
|
||||
const path = getRequestPath(ctx.request);
|
||||
if (!needsToken(path)) {
|
||||
console.log("[token-interceptor] onRequestToken SKIP (no-token path)", { path });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getAuthToken();
|
||||
if (token) {
|
||||
// ofetch 推荐:改 ctx.options.headers(**不**依赖 ctx.request 是 Request 实例 —— 实例类型不稳定)
|
||||
const headers = new Headers(ctx.options.headers);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
ctx.options.headers = headers;
|
||||
console.log("[token-interceptor] onRequestToken SET Authorization header (via ctx.options.headers)", {
|
||||
path,
|
||||
length: token.length,
|
||||
prefix: token.slice(0, 8),
|
||||
});
|
||||
} else {
|
||||
console.warn("[token-interceptor] onRequestToken NO token to set", { path });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 兼容旧接口的 FetchHooks 形式
|
||||
*/
|
||||
export const tokenInterceptor = {
|
||||
onRequest: onRequestToken,
|
||||
};
|
||||
Reference in New Issue
Block a user