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

@@ -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;

View File

@@ -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: '下一关' }));

View File

@@ -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([]),
]);

View File

@@ -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

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

View File

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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

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

View File

@@ -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 : [];

View File

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

View File

@@ -16,6 +16,12 @@ import {
} from './rpgEntryLibraryClient';
vi.mock('../apiClient', () => ({
BACKGROUND_AUTH_REQUEST_OPTIONS: {
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
},
requestJson: requestJsonMock,
}));

View File

@@ -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 : [];

View File

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

View File

@@ -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 : [];

View File

@@ -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,
},

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,