fix recommend runtime auth isolation

This commit is contained in:
2026-05-09 16:08:40 +08:00
parent 9ca66715a4
commit 80a4183b45
14 changed files with 292 additions and 25 deletions

View File

@@ -382,6 +382,11 @@ 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,
};
function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
const rawTime = entry.publishedAt ?? entry.updatedAt;
@@ -3495,13 +3500,23 @@ export function PlatformEntryFlowShellImpl({
? visualNovelWork
: null;
if (!workDetail) {
const response = await getVisualNovelWorkDetail(targetProfileId);
const response = await getVisualNovelWorkDetail(
targetProfileId,
options.embedded ? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS : {},
);
workDetail = response.work;
}
const { run } = await startVisualNovelRun(targetProfileId, {
const startRunPayload = {
profileId: targetProfileId,
mode: 'play',
});
mode: 'play' as const,
};
const { run } = options.embedded
? await startVisualNovelRun(
targetProfileId,
startRunPayload,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
: await startVisualNovelRun(targetProfileId, startRunPayload);
setVisualNovelWork(workDetail);
setVisualNovelRun(run);
setVisualNovelRuntimeReturnStage(returnStage);
@@ -4004,10 +4019,16 @@ export function PlatformEntryFlowShellImpl({
try {
const item =
detailItem ?? (await getPuzzleGalleryDetail(profileId)).item;
const { run } = await startPuzzleRun({
const startRunPayload = {
profileId: item.profileId,
levelId: levelId ?? null,
});
};
const { run } = options.embedded
? await startPuzzleRun(
startRunPayload,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
: await startPuzzleRun(startRunPayload);
setSelectedPuzzleDetail(item);
setPuzzleRun(run);
setPuzzleRuntimeReturnStage(returnStage);
@@ -4057,7 +4078,12 @@ export function PlatformEntryFlowShellImpl({
setMatch3DError(null);
try {
const { run } = await startMatch3DRun(profile.profileId);
const { run } = options.embedded
? await startMatch3DRun(
profile.profileId,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
: await startMatch3DRun(profile.profileId);
setMatch3DRun(run);
setMatch3DRuntimeReturnStage(returnStage);
if (!options.embedded) {
@@ -4110,7 +4136,12 @@ export function PlatformEntryFlowShellImpl({
setSquareHoleError(null);
try {
const { run } = await startSquareHoleRun(profile.profileId);
const { run } = options.embedded
? await startSquareHoleRun(
profile.profileId,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
: await startSquareHoleRun(profile.profileId);
setSquareHoleRun(run);
setSquareHoleRuntimeReturnStage(returnStage);
if (!options.embedded) {
@@ -4279,12 +4310,21 @@ export function PlatformEntryFlowShellImpl({
const elapsedMs = Math.max(1_000, Date.now() - bigFishRuntimeStartedAt);
setBigFishRuntimeStartedAt(null);
void recordBigFishPlay(sessionId, { elapsedMs }).catch((error) => {
const reportPromise =
activeRecommendRuntimeKind === 'big-fish'
? recordBigFishPlay(
sessionId,
{ elapsedMs },
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
: recordBigFishPlay(sessionId, { elapsedMs });
void reportPromise.catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩时长失败。'),
);
});
}, [
activeRecommendRuntimeKind,
bigFishRun?.sessionId,
bigFishRuntimeStartedAt,
resolveBigFishErrorMessage,
@@ -5844,7 +5884,12 @@ export function PlatformEntryFlowShellImpl({
setBigFishRuntimeReturnStage(returnStage);
setBigFishRun(null);
try {
const { run } = await startBigFishRuntimeRun(sessionId);
const { run } = options.embedded
? await startBigFishRuntimeRun(
sessionId,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
: await startBigFishRuntimeRun(sessionId);
setBigFishRuntimeStartedAt(Date.now());
setBigFishRun(run);
if (!options.embedded) {
@@ -5853,7 +5898,14 @@ export function PlatformEntryFlowShellImpl({
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
);
}
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
const recordPlayPromise = options.embedded
? recordBigFishPlay(
sessionId,
{ elapsedMs: 0 },
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
: recordBigFishPlay(sessionId, { elapsedMs: 0 });
void recordPlayPromise.catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
);
@@ -6410,6 +6462,7 @@ export function PlatformEntryFlowShellImpl({
if (
selectionStage !== 'platform' ||
platformBootstrap.platformTab !== 'home' ||
!platformBootstrap.canReadProtectedData ||
platformBootstrap.isLoadingPlatform
) {
return;
@@ -6439,6 +6492,7 @@ export function PlatformEntryFlowShellImpl({
}, [
activeRecommendEntryKey,
isStartingRecommendEntry,
platformBootstrap.canReadProtectedData,
platformBootstrap.isLoadingPlatform,
platformBootstrap.platformTab,
recommendRuntimeEntries,

View File

@@ -3018,6 +3018,51 @@ test('published puzzle works appear on home and mobile game category channel', a
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
});
test('home recommendation starts embedded puzzle without global auth reset on local failure', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-public-1',
authorDisplayName: '拼图作者',
levelName: '星桥机关',
summary: '旋转碎片并接通星桥机关。',
themeTags: ['机关', '星桥'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [publishedPuzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: 'puzzle-profile-public-1',
levelId: null,
},
{
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
},
);
});
});
test('published big fish works stay hidden from platform home and game category channel', async () => {
const user = userEvent.setup();
const publishedBigFishWork: BigFishWorkSummary = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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