fix recommend runtime auth isolation
This commit is contained in:
@@ -243,6 +243,28 @@ describe('apiClient', () => {
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
});
|
||||
|
||||
it('keeps auth state untouched when local background requests opt out of unauthorized clearing', async () => {
|
||||
setStoredAccessToken('still-valid-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
const response = await fetchWithApiAuth(
|
||||
'/api/runtime/puzzle/runs',
|
||||
{ method: 'POST' },
|
||||
{
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
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
|
||||
|
||||
@@ -34,6 +34,8 @@ export type ApiRequestOptions = {
|
||||
skipRefresh?: boolean;
|
||||
// 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。
|
||||
notifyAuthStateChange?: boolean;
|
||||
// 推荐页自动加载作品这类局部后台请求失败时,只应让当前卡片报错,不应清空全局登录态。
|
||||
clearAuthOnUnauthorized?: boolean;
|
||||
};
|
||||
|
||||
type ResolvedRetryOptions = {
|
||||
@@ -525,6 +527,8 @@ 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 requestSignal = init.signal ?? undefined;
|
||||
let attempt = 0;
|
||||
let refreshAttempted = false;
|
||||
@@ -580,7 +584,7 @@ export async function fetchWithApiAuth(
|
||||
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
|
||||
continue;
|
||||
} catch {
|
||||
if (hasAuthHeader) {
|
||||
if (hasAuthHeader && shouldClearAuthOnUnauthorized) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
if (shouldNotifyAuthStateChange) {
|
||||
@@ -593,7 +597,9 @@ export async function fetchWithApiAuth(
|
||||
!options.skipAuth &&
|
||||
!refreshAttempted
|
||||
) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
if (shouldClearAuthOnUnauthorized) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
if (shouldNotifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import type {
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import {
|
||||
type ApiRequestOptions,
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
|
||||
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
@@ -12,6 +16,10 @@ const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
type BigFishRuntimeRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
|
||||
/**
|
||||
* 上报大鱼吃小鱼正式游玩。elapsedMs 为 0 时仅标记玩过作品。
|
||||
@@ -19,6 +27,7 @@ const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
export function recordBigFishPlay(
|
||||
sessionId: string,
|
||||
payload: RecordBigFishPlayRequest,
|
||||
options: BigFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestJson<BigFishWorksResponse>(
|
||||
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`,
|
||||
@@ -30,11 +39,17 @@ export function recordBigFishPlay(
|
||||
'记录大鱼吃小鱼游玩失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function startBigFishRun(sessionId: string) {
|
||||
export function startBigFishRun(
|
||||
sessionId: string,
|
||||
options: BigFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/runs`,
|
||||
{
|
||||
@@ -43,6 +58,9 @@ export function startBigFishRun(sessionId: string) {
|
||||
'启动大鱼吃小鱼玩法失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import type {
|
||||
Match3DRunResponse,
|
||||
StopMatch3DRunRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import {
|
||||
type ApiRequestOptions,
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
|
||||
const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
@@ -20,6 +24,10 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
type Match3DRuntimeRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
|
||||
function normalizeRejectStatus(reason?: Match3DClickRejectReason | null) {
|
||||
switch (reason) {
|
||||
@@ -58,7 +66,10 @@ function mapClickConfirmation(
|
||||
/**
|
||||
* 基于作品启动一局抓大鹅正式 run。
|
||||
*/
|
||||
export function startMatch3DRun(profileId: string) {
|
||||
export function startMatch3DRun(
|
||||
profileId: string,
|
||||
options: Match3DRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`,
|
||||
{
|
||||
@@ -67,7 +78,12 @@ export function startMatch3DRun(profileId: string) {
|
||||
body: JSON.stringify({ profileId }),
|
||||
},
|
||||
'启动抓大鹅玩法失败',
|
||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||
{
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,11 @@ import type {
|
||||
UpdatePuzzleRuntimePauseRequest,
|
||||
UsePuzzleRuntimePropRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import {
|
||||
type ApiRequestOptions,
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
|
||||
const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs';
|
||||
const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
@@ -22,11 +26,18 @@ const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
type PuzzleRuntimeRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
|
||||
/**
|
||||
* 从某个已发布拼图作品开始一次 run。
|
||||
*/
|
||||
export async function startPuzzleRun(payload: StartPuzzleRunRequest) {
|
||||
export async function startPuzzleRun(
|
||||
payload: StartPuzzleRunRequest,
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
PUZZLE_RUNTIME_API_BASE,
|
||||
{
|
||||
@@ -37,6 +48,9 @@ export async function startPuzzleRun(payload: StartPuzzleRunRequest) {
|
||||
'启动拼图玩法失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export type RuntimeRequestOptions = {
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
notifyAuthStateChange?: boolean;
|
||||
clearAuthOnUnauthorized?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -50,6 +52,8 @@ export function requestRpgRuntimeJson<T>(
|
||||
retry,
|
||||
skipAuth: options.skipAuth,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import type {
|
||||
SquareHoleRunResponse,
|
||||
StopSquareHoleRunRequest,
|
||||
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import {
|
||||
type ApiRequestOptions,
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
|
||||
const SQUARE_HOLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
@@ -17,11 +21,18 @@ const SQUARE_HOLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
type SquareHoleRuntimeRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
|
||||
/**
|
||||
* 基于作品启动一局方洞挑战正式 run。
|
||||
*/
|
||||
export function startSquareHoleRun(profileId: string) {
|
||||
export function startSquareHoleRun(
|
||||
profileId: string,
|
||||
options: SquareHoleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestJson<SquareHoleRunResponse>(
|
||||
`/api/runtime/square-hole/works/${encodeURIComponent(profileId)}/runs`,
|
||||
{
|
||||
@@ -30,7 +41,12 @@ export function startSquareHoleRun(profileId: string) {
|
||||
body: JSON.stringify({ profileId }),
|
||||
},
|
||||
'启动方洞挑战失败',
|
||||
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
|
||||
{
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import {
|
||||
type ApiRequestOptions,
|
||||
type ApiRetryOptions,
|
||||
fetchWithApiAuth,
|
||||
requestJson,
|
||||
@@ -41,6 +42,10 @@ const VISUAL_NOVEL_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
export type VisualNovelRuntimeStreamOptions = TextStreamOptions & {
|
||||
onEvent?: (event: VisualNovelRuntimeStreamEvent) => void;
|
||||
};
|
||||
type VisualNovelRuntimeRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
|
||||
export type VisualNovelSaveArchiveResumeResponse =
|
||||
ProfileSaveArchiveResumeResponse<
|
||||
@@ -97,6 +102,7 @@ async function openVisualNovelRuntimeSsePost(
|
||||
export async function startVisualNovelRun(
|
||||
profileId: string,
|
||||
payload: VisualNovelStartRunRequest,
|
||||
options: VisualNovelRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestJson<VisualNovelRunResponse>(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}/runs`,
|
||||
@@ -105,6 +111,9 @@ export async function startVisualNovelRun(
|
||||
{
|
||||
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
|
||||
timeoutMs: 15000,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import type {
|
||||
VisualNovelWorkResponse,
|
||||
VisualNovelWorksResponse,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import {
|
||||
type ApiRequestOptions,
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
|
||||
const VISUAL_NOVEL_WORKS_API_BASE = '/api/creation/visual-novel/works';
|
||||
const VISUAL_NOVEL_WORKS_READ_RETRY: ApiRetryOptions = {
|
||||
@@ -17,6 +21,10 @@ const VISUAL_NOVEL_WORKS_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxDelayMs: 620,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
type VisualNovelWorksRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
|
||||
export function listVisualNovelWorks() {
|
||||
return requestJson<VisualNovelWorksResponse>(
|
||||
@@ -29,13 +37,19 @@ export function listVisualNovelWorks() {
|
||||
);
|
||||
}
|
||||
|
||||
export function getVisualNovelWorkDetail(profileId: string) {
|
||||
export function getVisualNovelWorkDetail(
|
||||
profileId: string,
|
||||
options: VisualNovelWorksRequestOptions = {},
|
||||
) {
|
||||
return requestJson<VisualNovelWorkResponse>(
|
||||
`${VISUAL_NOVEL_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取视觉小说作品详情失败',
|
||||
{
|
||||
retry: VISUAL_NOVEL_WORKS_READ_RETRY,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user