1
This commit is contained in:
@@ -99,7 +99,10 @@ import {
|
||||
buildPublicWorkStagePath,
|
||||
pushAppHistoryPath,
|
||||
} from '../../routing/appPageRoutes';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import {
|
||||
ApiClientError,
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
} from '../../services/apiClient';
|
||||
import {
|
||||
getPublicAuthUserByCode,
|
||||
getPublicAuthUserById,
|
||||
@@ -383,11 +386,8 @@ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
|
||||
'publish_missing_main_chapter',
|
||||
'publish_missing_first_act',
|
||||
]);
|
||||
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS = {
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
};
|
||||
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS =
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS;
|
||||
const PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS =
|
||||
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
|
||||
|
||||
|
||||
@@ -211,6 +211,7 @@ async function openExistingRpgDraft(
|
||||
}
|
||||
|
||||
const ISOLATED_RUNTIME_AUTH_OPTIONS = {
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
@@ -3682,6 +3683,10 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
|
||||
},
|
||||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
vi.mocked(listProfileSaveArchives).mockClear();
|
||||
vi.mocked(listProfileSaveArchives).mockRejectedValueOnce(
|
||||
new Error('后台存档刷新 401'),
|
||||
);
|
||||
|
||||
await user.click(document.querySelector('[data-piece-id="piece-0"]')!);
|
||||
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
|
||||
@@ -3711,6 +3716,9 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
|
||||
);
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(screen.getByText('测试玩家')).toBeTruthy();
|
||||
expect(listProfileSaveArchives).toHaveBeenCalledWith(
|
||||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: '下一关' }));
|
||||
|
||||
|
||||
@@ -22,14 +22,22 @@ import {
|
||||
resumeRpgProfileSaveArchive,
|
||||
upsertRpgProfileBrowseHistory,
|
||||
} from '../../services/rpg-entry';
|
||||
import {
|
||||
RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
type RuntimeRequestOptions,
|
||||
} from '../../services/rpg-runtime/rpgRuntimeRequest';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type { PlatformHomeTab } from './RpgEntryHomeView';
|
||||
import { resolveRpgEntryErrorMessage } from './rpgEntryShared';
|
||||
|
||||
const PLATFORM_BOOTSTRAP_AUTH_OPTIONS = RUNTIME_BACKGROUND_AUTH_OPTIONS;
|
||||
|
||||
type UseRpgEntryBootstrapParams = {
|
||||
user: AuthUser | null | undefined;
|
||||
canAccessProtectedData?: boolean | undefined;
|
||||
getProfileDashboard: () => Promise<ProfileDashboardSummary | null>;
|
||||
getProfileDashboard: (
|
||||
options?: RuntimeRequestOptions,
|
||||
) => Promise<ProfileDashboardSummary | null>;
|
||||
handleContinueGame: (
|
||||
snapshot?: HydratedSavedGameSnapshot | null,
|
||||
) => void;
|
||||
@@ -99,7 +107,9 @@ export function useRpgEntryBootstrap(
|
||||
setDashboardError(null);
|
||||
|
||||
try {
|
||||
setProfileDashboard(await getProfileDashboard());
|
||||
setProfileDashboard(
|
||||
await getProfileDashboard(PLATFORM_BOOTSTRAP_AUTH_OPTIONS),
|
||||
);
|
||||
} catch (error) {
|
||||
setDashboardError(
|
||||
resolveRpgEntryErrorMessage(error, '读取个人数据看板失败。'),
|
||||
@@ -115,7 +125,9 @@ export function useRpgEntryBootstrap(
|
||||
return [];
|
||||
}
|
||||
|
||||
const nextItems = await listRpgCreationWorks();
|
||||
const nextItems = await listRpgCreationWorks(
|
||||
PLATFORM_BOOTSTRAP_AUTH_OPTIONS,
|
||||
);
|
||||
setCustomWorldWorkEntries(nextItems);
|
||||
return nextItems;
|
||||
}, [canReadProtectedData, user]);
|
||||
@@ -132,7 +144,9 @@ export function useRpgEntryBootstrap(
|
||||
return [];
|
||||
}
|
||||
|
||||
const nextEntries = await listRpgEntryWorldLibrary();
|
||||
const nextEntries = await listRpgEntryWorldLibrary(
|
||||
PLATFORM_BOOTSTRAP_AUTH_OPTIONS,
|
||||
);
|
||||
setSavedCustomWorldEntries(nextEntries);
|
||||
return nextEntries;
|
||||
}, [canReadProtectedData, user]);
|
||||
@@ -147,7 +161,9 @@ export function useRpgEntryBootstrap(
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
const nextEntries = await listRpgProfileSaveArchives();
|
||||
const nextEntries = await listRpgProfileSaveArchives(
|
||||
PLATFORM_BOOTSTRAP_AUTH_OPTIONS,
|
||||
);
|
||||
setSaveEntries(nextEntries);
|
||||
return nextEntries;
|
||||
} catch (error) {
|
||||
@@ -161,7 +177,10 @@ export function useRpgEntryBootstrap(
|
||||
setHistoryError(null);
|
||||
|
||||
try {
|
||||
const syncedEntries = await upsertRpgProfileBrowseHistory(entry);
|
||||
const syncedEntries = await upsertRpgProfileBrowseHistory(
|
||||
entry,
|
||||
PLATFORM_BOOTSTRAP_AUTH_OPTIONS,
|
||||
);
|
||||
setHistoryEntries(syncedEntries);
|
||||
} catch (error) {
|
||||
setHistoryError(
|
||||
@@ -237,18 +256,20 @@ export function useRpgEntryBootstrap(
|
||||
saveArchivesResult,
|
||||
] = await Promise.allSettled([
|
||||
canReadProtectedData
|
||||
? listRpgEntryWorldLibrary()
|
||||
? listRpgEntryWorldLibrary(PLATFORM_BOOTSTRAP_AUTH_OPTIONS)
|
||||
: Promise.resolve([]),
|
||||
canReadProtectedData
|
||||
? listRpgCreationWorks()
|
||||
? listRpgCreationWorks(PLATFORM_BOOTSTRAP_AUTH_OPTIONS)
|
||||
: Promise.resolve([]),
|
||||
listRpgEntryWorldGallery(),
|
||||
canReadProtectedData ? getProfileDashboard() : Promise.resolve(null),
|
||||
canReadProtectedData
|
||||
? listRpgProfileBrowseHistory()
|
||||
? getProfileDashboard(PLATFORM_BOOTSTRAP_AUTH_OPTIONS)
|
||||
: Promise.resolve(null),
|
||||
canReadProtectedData
|
||||
? listRpgProfileBrowseHistory(PLATFORM_BOOTSTRAP_AUTH_OPTIONS)
|
||||
: Promise.resolve([]),
|
||||
canReadProtectedData
|
||||
? listRpgProfileSaveArchives()
|
||||
? listRpgProfileSaveArchives(PLATFORM_BOOTSTRAP_AUTH_OPTIONS)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
ApiClientError,
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
clearStoredAccessToken,
|
||||
fetchWithApiAuth,
|
||||
getStoredAccessToken,
|
||||
@@ -265,6 +266,52 @@ describe('apiClient', () => {
|
||||
expect(getStoredAccessToken()).toBe('still-valid-token');
|
||||
});
|
||||
|
||||
it('keeps auth state untouched when local auth impact receives 401 with bearer token', async () => {
|
||||
setStoredAccessToken('still-valid-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
const response = await fetchWithApiAuth(
|
||||
'/api/profile/save-archives',
|
||||
{ method: 'GET' },
|
||||
{
|
||||
authImpact: 'local',
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('still-valid-token');
|
||||
});
|
||||
|
||||
it('does not clear local token when background refresh fails before a request', async () => {
|
||||
setStoredAccessToken('still-valid-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
const response = await fetchWithApiAuth(
|
||||
'/api/runtime/puzzle/runs',
|
||||
{ method: 'POST' },
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/puzzle/runs',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer still-valid-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||||
'/api/auth/refresh',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('still-valid-token');
|
||||
});
|
||||
|
||||
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ApiClientError,
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
type ApiRequestOptions,
|
||||
requestJson,
|
||||
} from './apiClient';
|
||||
@@ -45,11 +46,8 @@ type CachedReadUrlFailureEntry = {
|
||||
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
|
||||
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
|
||||
const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000;
|
||||
const ASSET_READ_URL_BACKGROUND_OPTIONS = {
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
} satisfies ApiRequestOptions;
|
||||
const ASSET_READ_URL_BACKGROUND_OPTIONS =
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS satisfies ApiRequestOptions;
|
||||
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
|
||||
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
|
||||
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
|
||||
|
||||
@@ -18,7 +18,10 @@ const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
};
|
||||
type BigFishRuntimeRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||
| 'authImpact'
|
||||
| 'skipRefresh'
|
||||
| 'notifyAuthStateChange'
|
||||
| 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -39,6 +42,7 @@ export function recordBigFishPlay(
|
||||
'记录大鱼吃小鱼游玩失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
@@ -58,6 +62,7 @@ export function startBigFishRun(
|
||||
'启动大鱼吃小鱼玩法失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
|
||||
@@ -26,7 +26,10 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
};
|
||||
type Match3DRuntimeRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||
| 'authImpact'
|
||||
| 'skipRefresh'
|
||||
| 'notifyAuthStateChange'
|
||||
| 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
|
||||
function normalizeRejectStatus(reason?: Match3DClickRejectReason | null) {
|
||||
@@ -80,6 +83,7 @@ export function startMatch3DRun(
|
||||
'启动抓大鹅玩法失败',
|
||||
{
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
|
||||
@@ -28,7 +28,10 @@ const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
};
|
||||
type PuzzleRuntimeRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||
| 'authImpact'
|
||||
| 'skipRefresh'
|
||||
| 'notifyAuthStateChange'
|
||||
| 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -48,6 +51,7 @@ export async function startPuzzleRun(
|
||||
'启动拼图玩法失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
@@ -136,6 +140,7 @@ export async function advancePuzzleNextLevel(
|
||||
'进入下一关失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
@@ -161,6 +166,7 @@ export async function submitPuzzleLeaderboard(
|
||||
'提交拼图排行榜失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
@@ -186,6 +192,7 @@ export async function updatePuzzleRunPause(
|
||||
'更新拼图计时状态失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
@@ -211,6 +218,7 @@ export async function usePuzzleRuntimeProp(
|
||||
'使用拼图道具失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import {
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
type ApiAuthImpact,
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
@@ -18,9 +23,15 @@ export type RpgCreationRuntimeRequestOptions = {
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
authImpact?: ApiAuthImpact;
|
||||
notifyAuthStateChange?: boolean;
|
||||
clearAuthOnUnauthorized?: boolean;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export const RPG_CREATION_BACKGROUND_AUTH_OPTIONS =
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS satisfies RpgCreationRuntimeRequestOptions;
|
||||
|
||||
export function requestRpgCreationRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
@@ -43,6 +54,9 @@ export function requestRpgCreationRuntimeJson<T>(
|
||||
retry,
|
||||
skipAuth: options.skipAuth,
|
||||
skipRefresh: options.skipRefresh,
|
||||
authImpact: options.authImpact,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
timeoutMs: options.timeoutMs,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { ListRpgCreationWorksResponse } from '../../../packages/shared/src';
|
||||
import { requestRpgCreationRuntimeJson } from './rpgCreationRuntimeClient';
|
||||
import {
|
||||
requestRpgCreationRuntimeJson,
|
||||
type RpgCreationRuntimeRequestOptions,
|
||||
} from './rpgCreationRuntimeClient';
|
||||
|
||||
export async function listRpgCreationWorks() {
|
||||
export async function listRpgCreationWorks(
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<ListRpgCreationWorksResponse>(
|
||||
'/custom-world/works',
|
||||
{ method: 'GET' },
|
||||
'读取创作作品列表失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.items) ? response.items : [];
|
||||
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
} from './rpgEntryLibraryClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS: {
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
},
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
@@ -35,7 +41,11 @@ describe('rpgEntry profile browse history routes', () => {
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取浏览历史失败',
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -60,10 +70,14 @@ describe('rpgEntry profile browse history routes', () => {
|
||||
}),
|
||||
'写入浏览历史失败',
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -188,7 +202,11 @@ describe('rpgEntry save archive routes', () => {
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取存档列表失败',
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
} from './rpgEntryLibraryClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS: {
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
},
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
type RuntimeRequestOptions,
|
||||
requestPublicRpgRuntimeJson,
|
||||
requestRpgRuntimeJson,
|
||||
@@ -26,7 +27,10 @@ export async function listRpgEntryWorldLibrary(
|
||||
'/custom-world-library',
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
options,
|
||||
{
|
||||
...RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
|
||||
@@ -15,6 +15,12 @@ import {
|
||||
} from './rpgProfileClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS: {
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
},
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
@@ -32,7 +38,11 @@ describe('rpgProfileClient browse history routes', () => {
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取浏览历史失败',
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -57,10 +67,14 @@ describe('rpgProfileClient browse history routes', () => {
|
||||
}),
|
||||
'写入浏览历史失败',
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -121,7 +135,11 @@ describe('rpgProfileClient save archive routes', () => {
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取存档列表失败',
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
requestRpgRuntimeJson,
|
||||
type RuntimeRequestOptions,
|
||||
} from '../rpg-runtime/rpgRuntimeRequest';
|
||||
@@ -199,7 +200,10 @@ export async function listRpgProfileSaveArchives(
|
||||
'/profile/save-archives',
|
||||
{ method: 'GET' },
|
||||
'读取存档列表失败',
|
||||
options,
|
||||
{
|
||||
...RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
@@ -231,7 +235,10 @@ export async function listRpgProfileBrowseHistory(
|
||||
'/profile/browse-history',
|
||||
{ method: 'GET' },
|
||||
'读取浏览历史失败',
|
||||
options,
|
||||
{
|
||||
...RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
@@ -249,7 +256,10 @@ export async function upsertRpgProfileBrowseHistory(
|
||||
body: JSON.stringify(entry),
|
||||
},
|
||||
'写入浏览历史失败',
|
||||
options,
|
||||
{
|
||||
...RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import {
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
type ApiAuthImpact,
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
@@ -18,10 +23,14 @@ export type RuntimeRequestOptions = {
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
authImpact?: ApiAuthImpact;
|
||||
notifyAuthStateChange?: boolean;
|
||||
clearAuthOnUnauthorized?: boolean;
|
||||
};
|
||||
|
||||
export const RUNTIME_BACKGROUND_AUTH_OPTIONS =
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS satisfies RuntimeRequestOptions;
|
||||
|
||||
/**
|
||||
* 统一封装 RPG 运行时域的请求重试与鉴权透传,避免各 client 重复维护同一套规则。
|
||||
*/
|
||||
@@ -52,6 +61,7 @@ export function requestRpgRuntimeJson<T>(
|
||||
retry,
|
||||
skipAuth: options.skipAuth,
|
||||
skipRefresh: options.skipRefresh,
|
||||
authImpact: options.authImpact,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
},
|
||||
|
||||
@@ -23,7 +23,10 @@ const SQUARE_HOLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
};
|
||||
type SquareHoleRuntimeRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||
| 'authImpact'
|
||||
| 'skipRefresh'
|
||||
| 'notifyAuthStateChange'
|
||||
| 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -43,6 +46,7 @@ export function startSquareHoleRun(
|
||||
'启动方洞挑战失败',
|
||||
{
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
|
||||
@@ -44,7 +44,10 @@ export type VisualNovelRuntimeStreamOptions = TextStreamOptions & {
|
||||
};
|
||||
type VisualNovelRuntimeRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||
| 'authImpact'
|
||||
| 'skipRefresh'
|
||||
| 'notifyAuthStateChange'
|
||||
| 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
|
||||
export type VisualNovelSaveArchiveResumeResponse =
|
||||
@@ -111,6 +114,7 @@ export async function startVisualNovelRun(
|
||||
{
|
||||
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
|
||||
timeoutMs: 15000,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
|
||||
@@ -23,7 +23,10 @@ const VISUAL_NOVEL_WORKS_WRITE_RETRY: ApiRetryOptions = {
|
||||
};
|
||||
type VisualNovelWorksRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||
| 'authImpact'
|
||||
| 'skipRefresh'
|
||||
| 'notifyAuthStateChange'
|
||||
| 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
|
||||
export function listVisualNovelWorks() {
|
||||
@@ -47,6 +50,7 @@ export function getVisualNovelWorkDetail(
|
||||
'读取视觉小说作品详情失败',
|
||||
{
|
||||
retry: VISUAL_NOVEL_WORKS_READ_RETRY,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
|
||||
Reference in New Issue
Block a user