diff --git a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md index 8b1d8574..fd557e2f 100644 --- a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md +++ b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md @@ -149,8 +149,15 @@ - 不主动附带 `Authorization` - 不因本地 access token 失效去触发 `/api/auth/refresh` +- 若当前请求本身没有携带 access token,也不允许因为返回 `401` 就额外触发 `/api/auth/refresh` - refresh cookie 缺失、refresh 失败、账号状态过期时,不能把首页公开作品广场一起拖成错误态 +受保护工作区恢复补充约束: + +- 若 URL 或 `sessionStorage` 中残留 `customWorldSessionId`、`customWorldOperationId` 等共创工作区恢复标记,未登录态下不能直接请求对应的 Agent 会话或操作接口 +- 这类“恢复共创工作区”场景要先弹出登录弹窗,登录成功后再恢复原本的工作区或操作上下文 +- 用户未登录且关闭登录弹窗时,前端保持平台页可浏览状态,不允许持续轮询受保护接口 + 未登录时不读取: - 自定义世界库 diff --git a/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx b/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx index 46f52405..e6662205 100644 --- a/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx @@ -367,7 +367,7 @@ beforeEach(() => { test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => { const user = userEvent.setup(); - render(); + render(); await clickFirstButtonByName(user, '创作'); await clickFirstButtonByName(user, /开启新的创作/u); @@ -456,10 +456,37 @@ test('selecting RPG creation while logged out routes through requireAuth', async expect(createCustomWorldAgentSession).not.toHaveBeenCalled(); }); +test('restoring an agent workspace while logged out opens login modal before loading the protected session', async () => { + const openLoginModal = vi.fn(); + + window.history.replaceState( + null, + '', + '/?customWorldSessionId=custom-world-agent-session-1', + ); + + render( + , + ); + + await waitFor(() => { + expect(openLoginModal).toHaveBeenCalledTimes(1); + }); + + expect(openLoginModal).toHaveBeenCalledWith(expect.any(Function)); + expect(getCustomWorldAgentSession).not.toHaveBeenCalled(); +}); + test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => { const user = userEvent.setup(); - render(); + render(); await clickFirstButtonByName(user, '创作'); await clickFirstButtonByName(user, /开启新的创作/u); @@ -593,7 +620,7 @@ test('existing draft sessions enter the legacy result layout directly', async () ], }); - render(); + render(); await clickFirstButtonByName(user, '创作'); await clickFirstButtonByName(user, /开启新的创作/u); diff --git a/src/components/game-shell/PreGameSelectionFlow.tsx b/src/components/game-shell/PreGameSelectionFlow.tsx index 46542469..20c4e21c 100644 --- a/src/components/game-shell/PreGameSelectionFlow.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.tsx @@ -186,6 +186,7 @@ export function PreGameSelectionFlow({ const authUi = useAuthUi(); const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState()); const hasAppliedInitialAgentWorkspaceRef = useRef(false); + const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false); const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] = useState(null); const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState< @@ -340,16 +341,29 @@ export function PreGameSelectionFlow({ ); useEffect(() => { - if (hasAppliedInitialAgentWorkspaceRef.current) { + const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId; + + if (!initialAgentSessionId || hasAppliedInitialAgentWorkspaceRef.current) { + return; + } + + setPlatformTab('create'); + + // URL 或 sessionStorage 中残留的共创工作区属于受保护入口, + // 未登录时只允许先唤起登录弹窗,不能直接恢复会话请求。 + if (!authUi?.user) { + if (!hasRequestedInitialAgentWorkspaceAuthRef.current) { + hasRequestedInitialAgentWorkspaceAuthRef.current = true; + authUi?.openLoginModal?.(() => { + setSelectionStage('agent-workspace'); + }); + } return; } hasAppliedInitialAgentWorkspaceRef.current = true; - if (initialAgentUiStateRef.current.activeSessionId) { - setPlatformTab('create'); - setSelectionStage('agent-workspace'); - } - }, [setSelectionStage]); + setSelectionStage('agent-workspace'); + }, [authUi?.openLoginModal, authUi?.user, setSelectionStage]); useEffect(() => { if (!selectedDetailEntry) { @@ -530,6 +544,16 @@ export function PreGameSelectionFlow({ useEffect(() => { if (!activeAgentSessionId) { setAgentSession(null); + setAgentOperation(null); + setIsLoadingAgentSession(false); + setStreamingAgentReplyText(''); + setIsStreamingAgentReply(false); + return; + } + + if (!authUi?.user) { + setAgentSession(null); + setAgentOperation(null); setIsLoadingAgentSession(false); setStreamingAgentReplyText(''); setIsStreamingAgentReply(false); @@ -572,13 +596,14 @@ export function PreGameSelectionFlow({ }; }, [ activeAgentSessionId, + authUi?.user, persistAgentUiState, setSelectionStage, syncAgentSessionSnapshot, ]); useEffect(() => { - if (!activeAgentSessionId || !activeAgentOperationId) { + if (!activeAgentSessionId || !activeAgentOperationId || !authUi?.user) { return; } @@ -638,6 +663,7 @@ export function PreGameSelectionFlow({ }, [ activeAgentOperationId, activeAgentSessionId, + authUi?.user, persistAgentUiState, syncAgentSessionSnapshot, ]); diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index cfd465bf..4962b9c4 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -157,6 +157,24 @@ describe('apiClient', () => { ); }); + it('does not refresh or emit auth changes for 401 responses without auth context', async () => { + fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); + + const response = await fetchWithApiAuth('/api/runtime/protected', { + method: 'GET', + }); + + expect(response.status).toBe(401); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + '/api/runtime/protected', + expect.objectContaining({ + credentials: 'same-origin', + }), + ); + expect(window.dispatchEvent).not.toHaveBeenCalled(); + }); + it('retries transient get requests before unwrapping the response envelope', async () => { fetchMock .mockRejectedValueOnce(new TypeError('network unavailable')) diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index e508c46a..1611e567 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -351,12 +351,15 @@ export function setStoredAccessToken( } const nextToken = token.trim(); + const previousToken = getStoredAccessToken(); if (nextToken) { window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken); } else { window.localStorage.removeItem(ACCESS_TOKEN_KEY); } - if (options.emit !== false) { + + // 只有登录态令牌真的发生变化时才广播事件,避免无意义的鉴权重算。 + if (options.emit !== false && previousToken !== nextToken) { emitAuthStateChange(); } } @@ -370,8 +373,11 @@ export function clearStoredAccessToken( return; } + const previousToken = getStoredAccessToken(); window.localStorage.removeItem(ACCESS_TOKEN_KEY); - if (options.emit !== false) { + + // 未登录态下重复清空 token 不应触发状态刷新,否则会放大 401 循环。 + if (options.emit !== false && previousToken) { emitAuthStateChange(); } } @@ -487,14 +493,20 @@ export async function fetchWithApiAuth( for (;;) { try { + const requestHeaders = withAuthorizationHeaders(init.headers, options); + const hasAuthHeader = Boolean( + requestHeaders.Authorization?.trim() || + requestHeaders.authorization?.trim(), + ); const response = await fetch(input, { credentials: 'same-origin', ...init, - headers: withAuthorizationHeaders(init.headers, options), + headers: requestHeaders, }); if ( response.status === 401 && + hasAuthHeader && !options.skipAuth && !options.skipRefresh && !refreshAttempted