This commit is contained in:
2026-05-09 18:24:08 +08:00
parent a0ed128bde
commit bc704d0c22
38 changed files with 481 additions and 378 deletions

View File

@@ -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();
}
}