This commit is contained in:
2026-04-21 10:30:12 +08:00
parent ae28dab032
commit 13bc79306f
49 changed files with 3691 additions and 1357 deletions

View File

@@ -7,8 +7,8 @@ import {
parseApiErrorMessage,
unwrapApiResponse,
} from '../../packages/shared/src/http';
import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth';
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
const REQUEST_ID_HEADER = 'x-request-id';
const API_VERSION_HEADER = 'x-api-version';
@@ -30,6 +30,8 @@ export type ApiRequestOptions = {
skipAuth?: boolean;
omitEnvelopeHeader?: boolean;
skipRefresh?: boolean;
// 会话探测类请求需要静默处理 401避免 AuthGate 因自发广播再次触发 hydrate。
notifyAuthStateChange?: boolean;
};
type ResolvedRetryOptions = {
@@ -48,10 +50,6 @@ type ParsedApiErrorShape = {
meta: Partial<ApiMeta>;
};
type RefreshTokenResponse = {
token: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
@@ -311,11 +309,7 @@ export class ApiClientError extends Error {
}
}
function canUseLocalStorage() {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
}
function emitAuthStateChange() {
export function emitAuthStateChange() {
if (typeof window === 'undefined') {
return;
}
@@ -330,72 +324,18 @@ function emitAuthStateChange() {
}
}
export function getStoredAccessToken() {
if (!canUseLocalStorage()) {
return '';
}
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
}
export function setStoredAccessToken(
token: string,
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
const nextToken = token.trim();
const previousToken = getStoredAccessToken();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken);
} else {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
}
// 只有登录态令牌真的发生变化时才广播事件,避免无意义的鉴权重算。
if (options.emit !== false && previousToken !== nextToken) {
emitAuthStateChange();
}
}
export function clearStoredAccessToken(
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
const previousToken = getStoredAccessToken();
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
// 未登录态下重复清空 token 不应触发状态刷新,否则会放大 401 循环。
if (options.emit !== false && previousToken) {
emitAuthStateChange();
}
}
function withAuthorizationHeaders(
function withApiHeaders(
headers?: HeadersInit,
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader'> = {},
) {
const nextHeaders = normalizeHeaders(headers);
const token = getStoredAccessToken();
if (token && !options.skipAuth) {
nextHeaders.Authorization = `Bearer ${token}`;
}
if (!options.omitEnvelopeHeader) {
nextHeaders[API_RESPONSE_ENVELOPE_HEADER] = API_RESPONSE_ENVELOPE_VERSION;
}
return nextHeaders;
}
let refreshAccessTokenPromise: Promise<string> | null = null;
let refreshAccessTokenPromise: Promise<void> | null = null;
async function refreshAccessToken() {
if (refreshAccessTokenPromise) {
@@ -412,24 +352,19 @@ async function refreshAccessToken() {
});
if (!response.ok) {
clearStoredAccessToken();
throw await buildApiClientError(response, '刷新登录状态失败');
}
const responseText = await response.text();
const payload = responseText
? unwrapApiResponse<RefreshTokenResponse>(
JSON.parse(responseText) as RefreshTokenResponse,
? unwrapApiResponse<AuthRefreshResponse>(
JSON.parse(responseText) as AuthRefreshResponse,
)
: null;
if (!payload?.token?.trim()) {
clearStoredAccessToken();
if (payload?.ok !== true) {
throw new Error('刷新登录状态失败');
}
setStoredAccessToken(payload.token, { emit: false });
return payload.token;
})();
try {
@@ -446,25 +381,20 @@ export async function fetchWithApiAuth(
) {
const method = (init.method ?? 'GET').toUpperCase();
const retry = resolveRetryOptions(method, options.retry);
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
let attempt = 0;
let refreshAttempted = false;
for (;;) {
try {
const requestHeaders = withAuthorizationHeaders(init.headers, options);
const hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
const response = await fetch(input, {
credentials: 'same-origin',
...init,
headers: requestHeaders,
headers: withApiHeaders(init.headers, options),
});
if (
response.status === 401 &&
hasAuthHeader &&
!options.skipAuth &&
!options.skipRefresh &&
!refreshAttempted
@@ -472,12 +402,19 @@ export async function fetchWithApiAuth(
try {
await refreshAccessToken();
refreshAttempted = true;
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
continue;
} catch {
clearStoredAccessToken();
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
}
} else if (response.status === 401 && !options.skipAuth) {
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
} else if (response.status === 401) {
clearStoredAccessToken();
}
if (!shouldRetryResponse(response.status, attempt, retry)) {