diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 9aca5c60..0d7c2698 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -134,6 +134,14 @@ - 验证:`npm run test -- src/components/auth/AuthGate.test.tsx`,新增用例应覆盖“旧 guest hydrate 不覆盖新登录态”。 - 关联:`src/components/auth/AuthGate.tsx`、`src/components/auth/AuthGate.test.tsx`、`docs/technical/AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md`。 +## 登录后推荐页加载出作品又回到未登录 + +- 现象:前端登录成功后进入推荐页,推荐页自动加载出一个作品,随后瞬间回到未登录;停留在其他页面或推荐页没加载出作品时不复现。 +- 原因:推荐页 embedded 运行态会自动发起受保护写请求。若这些卡片级后台请求遇到 `401` 或 refresh 失败,默认请求层曾清空 access token 并广播全局 auth 事件,导致 `AuthGate` 重新 hydrate 成未登录态。 +- 处理:推荐页自动运行态请求传 `skipRefresh: true`、`notifyAuthStateChange: false`、`clearAuthOnUnauthorized: false`,并等 `canReadProtectedData` 为 true 后再启动;用户主动点击的受保护动作仍保留默认鉴权失败处理。 +- 验证:`npm run test -- src/services/apiClient.test.ts` 和 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`。 +- 关联:`src/services/apiClient.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`。 + ## Rust 冷编译导致 api-server 健康检查误超时 - 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`。 diff --git a/docs/technical/README.md b/docs/technical/README.md index b8a8ab98..017c2654 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,7 @@ ## 文档列表 - [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。 +- [RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md](./RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md):记录平台推荐页自动加载作品后局部运行态请求 `401` 不应扩散成全局登出的修复,覆盖请求层局部鉴权失败隔离、推荐页 embedded 运行态启动和回归测试。 - [AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md](./AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md):记录 `AuthGate` 登录成功后又被旧 hydrate 覆盖回未登录态的竞态根因、版本号保护修复与回归测试。 - [VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md](./VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md):记录火山引擎大模型 ASR 双向流式、TTS WebSocket 双向流式和 TTS HTTP SSE 单向流式的后端代理、环境变量、协议帧和验收边界。 - [VECTOR_ENGINE_AUDIO_GENERATION_SUNO_VIDU_2026-05-08.md](./VECTOR_ENGINE_AUDIO_GENERATION_SUNO_VIDU_2026-05-08.md):记录视觉小说结果页接入 VectorEngine Suno 文生背景音乐与 Vidu 文生音效的接口、环境变量、后端路由、OSS 资产回写和前端弹层交互边界。 diff --git a/docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md b/docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md new file mode 100644 index 00000000..e353756d --- /dev/null +++ b/docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md @@ -0,0 +1,40 @@ +# 推荐页运行态鉴权失败隔离修复 + +日期:`2026-05-09` + +## 背景 + +登录成功进入平台推荐页后,推荐页会自动加载一个公开作品并启动嵌入式运行态。实际联调中出现过:作品刚加载出来,前端又瞬间回到未登录状态;停留在其他页面,或推荐页没有成功加载出作品时不会复现。 + +## 根因 + +推荐页首屏的作品运行态启动是后台自动副作用,不是用户主动点击的账号操作。它会触发多条受保护请求,例如: + +1. 拼图、抓大鹅、方洞挑战、视觉小说的 `start run`。 +2. 大鱼吃小鱼的 `start run` 与游玩记录上报。 +3. 视觉小说运行前的作品详情读取。 + +这些请求一旦遇到本地代理错配、后端短暂不可用或 token 刷新失败,原请求层会按普通受保护请求处理 `401`,清空 access token 并广播全局鉴权变更。`AuthGate` 收到事件后重新 hydrate,于是当前用户界面被切回未登录态。 + +## 修复 + +本次把推荐页自动运行态请求定义为“卡片级后台请求”: + +1. `apiClient` 增加 `clearAuthOnUnauthorized` 选项,允许局部请求在 `401` 时不清空全局 token。 +2. 推荐页嵌入式运行态请求统一传入 `skipRefresh: true`、`notifyAuthStateChange: false`、`clearAuthOnUnauthorized: false`。 +3. 推荐页自动启动作品前必须满足 `canReadProtectedData`,避免 `AuthGate` 仍在恢复阶段就提前发起受保护写请求。 +4. 普通用户主动点击“启动”、Remix、发布、点赞等路径继续保留默认全局鉴权处理。 + +## 验证 + +1. `npm run test -- src/services/apiClient.test.ts` +2. `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"` +3. `npm run typecheck` +4. `npm run check:encoding` + +## 关联文件 + +1. `src/services/apiClient.ts` +2. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +3. `src/services/*-runtime/*RuntimeClient.ts` +4. `src/services/visual-novel-works/visualNovelWorksClient.ts` diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 905a110e..0bb91bb2 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -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, diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 2ff53263..d0189aba 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -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(); + + 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 = { diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index b702eee1..4fcc7076 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -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 diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 714625bb..599471e9 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -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(); } diff --git a/src/services/big-fish-runtime/bigFishRuntimeClient.ts b/src/services/big-fish-runtime/bigFishRuntimeClient.ts index f0365e21..fc5ce283 100644 --- a/src/services/big-fish-runtime/bigFishRuntimeClient.ts +++ b/src/services/big-fish-runtime/bigFishRuntimeClient.ts @@ -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( `/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( `/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, }, ); } diff --git a/src/services/match3d-runtime/match3dRuntimeClient.ts b/src/services/match3d-runtime/match3dRuntimeClient.ts index ad25e224..d9bcbda6 100644 --- a/src/services/match3d-runtime/match3dRuntimeClient.ts +++ b/src/services/match3d-runtime/match3dRuntimeClient.ts @@ -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( `/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, + }, ); } diff --git a/src/services/puzzle-runtime/puzzleRuntimeClient.ts b/src/services/puzzle-runtime/puzzleRuntimeClient.ts index 5358fbe2..ddea7771 100644 --- a/src/services/puzzle-runtime/puzzleRuntimeClient.ts +++ b/src/services/puzzle-runtime/puzzleRuntimeClient.ts @@ -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( 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, }, ); } diff --git a/src/services/rpg-runtime/rpgRuntimeRequest.ts b/src/services/rpg-runtime/rpgRuntimeRequest.ts index b55abbdd..2c44ee7c 100644 --- a/src/services/rpg-runtime/rpgRuntimeRequest.ts +++ b/src/services/rpg-runtime/rpgRuntimeRequest.ts @@ -18,6 +18,8 @@ export type RuntimeRequestOptions = { retry?: ApiRetryOptions; skipAuth?: boolean; skipRefresh?: boolean; + notifyAuthStateChange?: boolean; + clearAuthOnUnauthorized?: boolean; }; /** @@ -50,6 +52,8 @@ export function requestRpgRuntimeJson( retry, skipAuth: options.skipAuth, skipRefresh: options.skipRefresh, + notifyAuthStateChange: options.notifyAuthStateChange, + clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, }, ); } diff --git a/src/services/square-hole-runtime/squareHoleRuntimeClient.ts b/src/services/square-hole-runtime/squareHoleRuntimeClient.ts index 74c40e64..7d8b3fd7 100644 --- a/src/services/square-hole-runtime/squareHoleRuntimeClient.ts +++ b/src/services/square-hole-runtime/squareHoleRuntimeClient.ts @@ -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( `/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, + }, ); } diff --git a/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts b/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts index a86519f1..4a7c116e 100644 --- a/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts +++ b/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts @@ -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( `${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, }, ); } diff --git a/src/services/visual-novel-works/visualNovelWorksClient.ts b/src/services/visual-novel-works/visualNovelWorksClient.ts index 333fb2c4..e2d0a181 100644 --- a/src/services/visual-novel-works/visualNovelWorksClient.ts +++ b/src/services/visual-novel-works/visualNovelWorksClient.ts @@ -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( @@ -29,13 +37,19 @@ export function listVisualNovelWorks() { ); } -export function getVisualNovelWorkDetail(profileId: string) { +export function getVisualNovelWorkDetail( + profileId: string, + options: VisualNovelWorksRequestOptions = {}, +) { return requestJson( `${VISUAL_NOVEL_WORKS_API_BASE}/${encodeURIComponent(profileId)}`, { method: 'GET' }, '读取视觉小说作品详情失败', { retry: VISUAL_NOVEL_WORKS_READ_RETRY, + skipRefresh: options.skipRefresh, + notifyAuthStateChange: options.notifyAuthStateChange, + clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, }, ); }