1
This commit is contained in:
@@ -26,18 +26,29 @@ export type ApiRetryOptions = {
|
||||
allowRetryMethods?: string[];
|
||||
};
|
||||
|
||||
export type ApiAuthImpact = 'global' | 'local';
|
||||
|
||||
export type ApiRequestOptions = {
|
||||
retry?: ApiRetryOptions;
|
||||
timeoutMs?: number;
|
||||
skipAuth?: boolean;
|
||||
omitEnvelopeHeader?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
// global:请求失败可影响整站登录态;local:失败只属于当前卡片、图片或运行态。
|
||||
authImpact?: ApiAuthImpact;
|
||||
// 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。
|
||||
notifyAuthStateChange?: boolean;
|
||||
// 推荐页自动加载作品这类局部后台请求失败时,只应让当前卡片报错,不应清空全局登录态。
|
||||
clearAuthOnUnauthorized?: boolean;
|
||||
};
|
||||
|
||||
export const BACKGROUND_AUTH_REQUEST_OPTIONS = {
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
} satisfies ApiRequestOptions;
|
||||
|
||||
type ResolvedRetryOptions = {
|
||||
maxRetries: number;
|
||||
baseDelayMs: number;
|
||||
@@ -54,6 +65,12 @@ type ParsedApiErrorShape = {
|
||||
meta: Partial<ApiMeta>;
|
||||
};
|
||||
|
||||
type ResolvedAuthFailurePolicy = {
|
||||
skipRefresh: boolean;
|
||||
notifyAuthStateChange: boolean;
|
||||
clearAuthOnUnauthorized: boolean;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
@@ -342,6 +359,24 @@ function buildRetryDelayMs(attempt: number, retry: ResolvedRetryOptions) {
|
||||
return Math.min(retry.maxDelayMs, retry.baseDelayMs * Math.max(1, attempt));
|
||||
}
|
||||
|
||||
function resolveAuthFailurePolicy(
|
||||
options: ApiRequestOptions,
|
||||
): ResolvedAuthFailurePolicy {
|
||||
const isLocalAuthImpact = options.authImpact === 'local';
|
||||
|
||||
return {
|
||||
// 局部后台请求可以携带已有 token,但不能主动 refresh;
|
||||
// 否则 refresh 失败会把一次卡片/图片/运行态失败放大成全局掉线。
|
||||
skipRefresh: isLocalAuthImpact || options.skipRefresh === true,
|
||||
notifyAuthStateChange: isLocalAuthImpact
|
||||
? false
|
||||
: options.notifyAuthStateChange !== false,
|
||||
clearAuthOnUnauthorized: isLocalAuthImpact
|
||||
? false
|
||||
: options.clearAuthOnUnauthorized !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
@@ -477,7 +512,6 @@ async function refreshAccessToken() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
throw await buildApiClientError(response, '刷新登录状态失败');
|
||||
}
|
||||
|
||||
@@ -489,7 +523,6 @@ async function refreshAccessToken() {
|
||||
: null;
|
||||
|
||||
if (payload?.ok !== true || !payload.token?.trim()) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
throw new Error('刷新登录状态失败');
|
||||
}
|
||||
|
||||
@@ -516,7 +549,12 @@ export async function ensureStoredAccessToken() {
|
||||
}
|
||||
|
||||
export async function refreshStoredAccessToken() {
|
||||
return refreshAccessToken();
|
||||
try {
|
||||
return await refreshAccessToken();
|
||||
} catch (error) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWithApiAuth(
|
||||
@@ -526,9 +564,7 @@ export async function fetchWithApiAuth(
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry = resolveRetryOptions(method, options.retry);
|
||||
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
|
||||
const shouldClearAuthOnUnauthorized =
|
||||
options.clearAuthOnUnauthorized !== false;
|
||||
const authFailurePolicy = resolveAuthFailurePolicy(options);
|
||||
const requestSignal = init.signal ?? undefined;
|
||||
let attempt = 0;
|
||||
let refreshAttempted = false;
|
||||
@@ -541,7 +577,11 @@ export async function fetchWithApiAuth(
|
||||
requestHeaders.authorization?.trim(),
|
||||
);
|
||||
|
||||
if (!hasAuthHeader && !options.skipAuth && !options.skipRefresh) {
|
||||
if (
|
||||
!hasAuthHeader &&
|
||||
!options.skipAuth &&
|
||||
!authFailurePolicy.skipRefresh
|
||||
) {
|
||||
try {
|
||||
// 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票,
|
||||
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
|
||||
@@ -573,7 +613,7 @@ export async function fetchWithApiAuth(
|
||||
response.status === 401 &&
|
||||
hasAuthHeader &&
|
||||
!options.skipAuth &&
|
||||
!options.skipRefresh &&
|
||||
!authFailurePolicy.skipRefresh &&
|
||||
!refreshAttempted
|
||||
) {
|
||||
try {
|
||||
@@ -584,10 +624,10 @@ export async function fetchWithApiAuth(
|
||||
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
|
||||
continue;
|
||||
} catch {
|
||||
if (hasAuthHeader && shouldClearAuthOnUnauthorized) {
|
||||
if (hasAuthHeader && authFailurePolicy.clearAuthOnUnauthorized) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
if (shouldNotifyAuthStateChange) {
|
||||
if (authFailurePolicy.notifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
@@ -597,10 +637,10 @@ export async function fetchWithApiAuth(
|
||||
!options.skipAuth &&
|
||||
!refreshAttempted
|
||||
) {
|
||||
if (shouldClearAuthOnUnauthorized) {
|
||||
if (authFailurePolicy.clearAuthOnUnauthorized) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
if (shouldNotifyAuthStateChange) {
|
||||
if (authFailurePolicy.notifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user