From c9a59f9edbfc4f2d905dffe4573945dfe559e9b7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 00:05:19 +0800 Subject: [PATCH] fix: clear creation hub cache on logout --- ...HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md | 26 +++++ docs/technical/README.md | 1 + src/components/auth/AuthGate.test.tsx | 63 +++++++++++- src/components/auth/AuthGate.tsx | 64 ++++++++++--- .../PlatformEntryFlowShellImpl.tsx | 64 ++++++++++++- ...gEntryFlowShell.agent.interaction.test.tsx | 95 +++++++++++++++++++ 6 files changed, 295 insertions(+), 18 deletions(-) create mode 100644 docs/technical/CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md diff --git a/docs/technical/CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md b/docs/technical/CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md new file mode 100644 index 00000000..399add56 --- /dev/null +++ b/docs/technical/CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md @@ -0,0 +1,26 @@ +# 创作中心退出登录私有缓存清理修复 2026-04-25 + +## 问题 + +点击退出登录后,页面未刷新时仍能切到创作中心,并看到上一位登录用户的作品。刷新页面后才恢复正常。 + +## 根因 + +1. `AuthGate` 的退出动作先等待 `/api/auth/logout` 完成,再通过全局鉴权事件重新 hydrate,期间前端 context 仍可能暴露旧用户。 +2. 平台创作入口里的 RPG works 会在 `canReadProtectedData=false` 时清空,但大鱼吃小鱼与拼图 works 是 `PlatformEntryFlowShellImpl` 内部 state,没有在退出登录时同步清空。 +3. 创作 Tab 会保持挂载以降低闪烁,因此私有作品数组只要留在内存里,就会继续被货架组件渲染。 + +## 修复口径 + +1. 用户触发退出当前设备或退出全部设备时,前端必须先本地收回 `user / canAccessProtectedData`,再等待后端吊销会话。 +2. `canReadProtectedData` 从 `true` 变为未登录态 `false` 时,创作中心必须清空所有私有作品缓存: + - RPG works / library 由 `useRpgEntryBootstrap` 清空。 + - Big Fish works、Puzzle works 由 `PlatformEntryFlowShellImpl` 清空。 + - 当前创作工作区、结果页、删除忙碌态与生成态一并复位。 +3. 公开广场与分类数据不受影响,仍按匿名公开接口读取。 + +## 验收 + +1. 点击退出登录后,不刷新页面进入创作 Tab,只能看到空作品货架,不再出现上一账号作品。 +2. 退出登录瞬间 `AuthUiContext.user` 为 `null`,`canAccessProtectedData=false`。 +3. 重新登录后按新账号重新拉取作品列表,不复用旧账号内存缓存。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 1a89ccef..8c68f645 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -14,6 +14,7 @@ - [CHARACTER_VISUAL_IP_MODERATION_FALLBACK_FIX_2026-04-25.md](./CHARACTER_VISUAL_IP_MODERATION_FALLBACK_FIX_2026-04-25.md):记录角色主形象遇到 DashScope `IPInfringementSuspect` 时自动改用原创安全 prompt 兜底重试的修复口径,并保留供应商原始错误便于排查。 - [CREATION_AGENT_IMMEDIATE_WAITING_DOTS_FIX_2026-04-25.md](./CREATION_AGENT_IMMEDIATE_WAITING_DOTS_FIX_2026-04-25.md):记录创作 Agent 用户发送消息后立刻展示三点等待动画的前端展示条件,避免首个 SSE token 到达前聊天区无反馈。 - [CREATION_AGENT_DOCUMENT_INPUT_UPLOAD_2026-04-25.md](./CREATION_AGENT_DOCUMENT_INPUT_UPLOAD_2026-04-25.md):冻结 Agent 创作页上传文本类文档并解析为输入框内容的前后端边界、接口、支持范围和验收标准。 +- [CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md](./CREATION_HUB_LOGOUT_PRIVATE_CACHE_FIX_2026-04-25.md):记录退出登录后创作中心仍显示上一账号作品的前端缓存根因,并冻结退出时立即收回鉴权上下文、清空三类私有作品货架缓存的修复口径。 - [CREATION_AGENT_CLIENT_AND_FLOW_CONTROLLER_REUSE_2026-04-25.md](./CREATION_AGENT_CLIENT_AND_FLOW_CONTROLLER_REUSE_2026-04-25.md):冻结三类作品创作 Agent client 通用工厂与平台轻量流程 controller 的复用边界,明确本轮只收口 HTTP/SSE 骨架和大鱼/拼图会话流程,不合并 RPG 自动保存主链。 - [BACKEND_CREATION_AGENT_LLM_TURN_COMMONIZATION_2026-04-25.md](./BACKEND_CREATION_AGENT_LLM_TURN_COMMONIZATION_2026-04-25.md):冻结后端创作 Agent LLM turn 公共化边界,收口模型可用性检查、流式 JSON 回复抽取、最终 JSON 解析与中文错误映射,玩法 schema 和写回逻辑继续留在各自模块。 - [CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md](./CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md):冻结创作中心作品货架统一视图模型,先在前端归一 RPG、大鱼、拼图 works 的展示字段、筛选状态和卡片动作语义,不新增后端聚合接口。 diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 5b793f01..a661263b 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -17,6 +17,8 @@ const authMocks = vi.hoisted(() => ({ getAuthLoginOptions: vi.fn(), getCurrentAuthUser: vi.fn(), loginWithPhoneCode: vi.fn(), + logoutAllAuthSessions: vi.fn(), + logoutAuthUser: vi.fn(), resetPassword: vi.fn(), sendPhoneLoginCode: vi.fn(), startWechatLogin: vi.fn(), @@ -44,8 +46,8 @@ vi.mock('../../services/authService', () => ({ getCaptchaChallengeFromError: vi.fn(() => null), liftAuthRiskBlock: vi.fn(), loginWithPhoneCode: authMocks.loginWithPhoneCode, - logoutAllAuthSessions: vi.fn(), - logoutAuthUser: vi.fn(), + logoutAllAuthSessions: authMocks.logoutAllAuthSessions, + logoutAuthUser: authMocks.logoutAuthUser, resetPassword: authMocks.resetPassword, revokeAuthSession: vi.fn(), sendPhoneLoginCode: authMocks.sendPhoneLoginCode, @@ -96,6 +98,8 @@ beforeEach(() => { authMocks.loginWithPhoneCode.mockResolvedValue(mockUser); authMocks.authEntry.mockResolvedValue(mockUser); authMocks.changePassword.mockResolvedValue(mockUser); + authMocks.logoutAllAuthSessions.mockResolvedValue(undefined); + authMocks.logoutAuthUser.mockResolvedValue(undefined); authMocks.resetPassword.mockResolvedValue(mockUser); authMocks.sendPhoneLoginCode.mockResolvedValue({ cooldownSeconds: 60, @@ -139,6 +143,27 @@ function PlatformTabStateProbe() { ); } +function LogoutStateProbe() { + const authUi = useAuthUi(); + + return ( +
+
当前用户:{authUi?.user?.displayName ?? '未登录'}
+
+ 私有数据:{authUi?.canAccessProtectedData ? '可读取' : '不可读取'} +
+ +
+ ); +} + test('auth gate keeps platform content visible when phone login is available', async () => { authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], @@ -276,6 +301,40 @@ test('auth state refresh keeps mounted platform content and local tab state', as expect(screen.getByText('当前Tab:创作')).toBeTruthy(); }); +test('logout withdraws user context before backend request finishes', async () => { + const user = userEvent.setup(); + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: mockUser, + availableLoginMethods: ['phone'], + }); + + let resolveLogout!: () => void; + const logoutPromise = new Promise((resolve) => { + resolveLogout = resolve; + }); + authMocks.logoutAuthUser.mockReturnValueOnce(logoutPromise); + + render( + + + , + ); + + expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy(); + expect(screen.getByText('私有数据:可读取')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '退出登录' })); + + expect(await screen.findByText('当前用户:未登录')).toBeTruthy(); + expect(screen.getByText('私有数据:不可读取')).toBeTruthy(); + expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveLogout(); + await logoutPromise; + }); +}); + test('auth gate shows sms send feedback in the login modal', async () => { const user = userEvent.setup(); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 195e4c03..f2a1c011 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -113,6 +113,50 @@ export function AuthGate({ children }: AuthGateProps) { setStatus('ready'); }, []); + const clearLocalAuthenticatedState = useCallback(() => { + // 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。 + // 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。 + pendingProtectedActionRef.current = null; + setUser(null); + setStatus('unauthenticated'); + setShowLoginModal(false); + setShowSettingsModal(false); + setInitialSettingsSection(null); + setSessions([]); + setAuditLogs([]); + setRiskBlocks([]); + setLoginCaptchaChallenge(null); + setBindCaptchaChallenge(null); + setChangePhoneCaptchaChallenge(null); + setError(''); + }, []); + + const logoutCurrentSession = useCallback(async () => { + clearLocalAuthenticatedState(); + try { + await logoutAuthUser(); + } catch (logoutError) { + setError( + logoutError instanceof Error + ? logoutError.message + : '退出登录失败,请刷新页面确认状态。', + ); + } + }, [clearLocalAuthenticatedState]); + + const logoutAllSessions = useCallback(async () => { + clearLocalAuthenticatedState(); + try { + await logoutAllAuthSessions(); + } catch (logoutError) { + setError( + logoutError instanceof Error + ? logoutError.message + : '退出全部设备失败,请刷新页面确认状态。', + ); + } + }, [clearLocalAuthenticatedState]); + const closeLoginModal = useCallback(() => { pendingProtectedActionRef.current = null; setShowLoginModal(false); @@ -400,10 +444,7 @@ export function AuthGate({ children }: AuthGateProps) { requireAuth, openSettingsModal, openAccountModal, - logout: async () => { - await logoutAuthUser(); - setShowSettingsModal(false); - }, + logout: logoutCurrentSession, musicVolume: settings.musicVolume, setMusicVolume: settings.setMusicVolume, platformTheme: settings.platformTheme, @@ -418,6 +459,7 @@ export function AuthGate({ children }: AuthGateProps) { openSettingsModal, readyUser, requireAuth, + logoutCurrentSession, status, settings.isHydratingSettings, settings.isPersistingSettings, @@ -494,9 +536,7 @@ export function AuthGate({ children }: AuthGateProps) { } }} onLogout={async () => { - await logoutAuthUser(); - setUser(null); - setStatus('unauthenticated'); + await logoutCurrentSession(); }} /> ); @@ -551,10 +591,7 @@ export function AuthGate({ children }: AuthGateProps) { settingsError={settings.settingsError} onClose={() => setShowSettingsModal(false)} onPlatformThemeChange={settings.setPlatformTheme} - onLogout={async () => { - await logoutAuthUser(); - setShowSettingsModal(false); - }} + onLogout={logoutCurrentSession} onRefreshRiskBlocks={async () => { setLoadingRiskBlocks(true); try { @@ -625,10 +662,7 @@ export function AuthGate({ children }: AuthGateProps) { ); } }} - onLogoutAll={async () => { - await logoutAllAuthSessions(); - setShowSettingsModal(false); - }} + onLogoutAll={logoutAllSessions} changePhoneCaptchaChallenge={changePhoneCaptchaChallenge} onSendChangePhoneCode={async (phone, captcha) => { try { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index bd41e520..0756721b 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -373,6 +373,7 @@ export function PlatformEntryFlowShellImpl({ const [deletingCreationWorkId, setDeletingCreationWorkId] = useState< string | null >(null); + const hadReadableProtectedDataRef = useRef(false); const hasInitialAgentSession = Boolean( readCustomWorldAgentUiState().activeSessionId, ); @@ -934,7 +935,13 @@ export function PlatformEntryFlowShellImpl({ const setIsPuzzleBusy = puzzleFlow.setIsBusy; const streamingPuzzleReplyText = puzzleFlow.streamingReplyText; const isStreamingPuzzleReply = puzzleFlow.isStreamingReply; - + const resetRpgSessionViewState = sessionController.resetSessionViewState; + const setRpgGeneratedCustomWorldProfile = + sessionController.setGeneratedCustomWorldProfile; + const setRpgCustomWorldError = sessionController.setCustomWorldError; + const persistRpgAgentUiState = sessionController.persistAgentUiState; + const resetAutoSaveTrackingToIdle = + autosaveCoordinator.resetAutoSaveTrackingToIdle; const openBigFishAgentWorkspace = useCallback(async () => { setBigFishRun(null); await bigFishFlow.openWorkspace(); @@ -946,6 +953,61 @@ export function PlatformEntryFlowShellImpl({ await puzzleFlow.openWorkspace(); }, [puzzleFlow]); + useEffect(() => { + if (platformBootstrap.canReadProtectedData) { + hadReadableProtectedDataRef.current = true; + return; + } + + if (authUi?.user || !hadReadableProtectedDataRef.current) { + return; + } + + hadReadableProtectedDataRef.current = false; + + // 创作中心只展示当前登录用户的私有作品。 + // 一旦退出登录或鉴权上下文被收回,三类作品缓存必须同步清空,不能等刷新页面。 + setShowCreationTypeModal(false); + setSelectedDetailEntry(null); + setBigFishWorks([]); + setBigFishRun(null); + setBigFishGenerationState(null); + setBigFishError(null); + setPuzzleOperation(null); + setPuzzleWorks([]); + setSelectedPuzzleDetail(null); + setPuzzleRun(null); + setPuzzleGenerationState(null); + setIsPuzzleNextLevelGenerating(false); + setPuzzleError(null); + setDeletingCreationWorkId(null); + resetRpgSessionViewState(); + setRpgGeneratedCustomWorldProfile(null); + setRpgCustomWorldError(null); + persistRpgAgentUiState(null, null); + resetAutoSaveTrackingToIdle(); + + if ( + selectionStage !== 'platform' && + selectionStage !== 'detail' && + selectionStage !== 'puzzle-gallery-detail' + ) { + setSelectionStage('platform'); + } + }, [ + authUi?.user, + platformBootstrap.canReadProtectedData, + persistRpgAgentUiState, + resetAutoSaveTrackingToIdle, + resetRpgSessionViewState, + selectionStage, + setBigFishError, + setPuzzleError, + setRpgCustomWorldError, + setRpgGeneratedCustomWorldProfile, + setSelectionStage, + ]); + const handleCreationHubCreateType = useCallback( (type: PlatformCreationTypeId) => { if (type === 'airp' || type === 'visual-novel') { diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index fd53f831..050403c5 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -1301,6 +1301,101 @@ test('clicking a public work while logged out routes through requireAuth', async expect(getRpgEntryWorldGalleryDetail).not.toHaveBeenCalled(); }); +test('creation hub clears all private work shelves immediately after logout state', async () => { + const user = userEvent.setup(); + const loggedInAuth = createAuthValue(); + const loggedOutAuth = createAuthValue({ + user: null, + canAccessProtectedData: false, + openLoginModal: () => {}, + requireAuth: () => {}, + }); + + vi.mocked(listRpgCreationWorks).mockResolvedValue([ + { + workId: 'draft:rpg-logout-cache-1', + sourceType: 'agent_session', + status: 'draft', + title: 'RPG 退出缓存作品', + subtitle: '登出后不应继续可见', + summary: '这条 RPG 私有作品只能在登录态展示。', + coverImageSrc: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + updatedAt: '2026-04-25T10:00:00.000Z', + publishedAt: null, + stage: 'clarifying', + stageLabel: '补齐关键锚点', + playableNpcCount: 0, + landmarkCount: 0, + roleVisualReadyCount: 0, + roleAnimationReadyCount: 0, + roleAssetSummaryLabel: null, + sessionId: 'rpg-logout-cache-session', + profileId: null, + canResume: true, + canEnterWorld: false, + }, + ]); + vi.mocked(listBigFishWorks).mockResolvedValue({ + items: [ + { + workId: 'big-fish-logout-cache-1', + sourceSessionId: 'big-fish-logout-cache-session', + title: '大鱼退出缓存作品', + subtitle: '登出后不应继续可见', + summary: '这条大鱼私有作品只能在登录态展示。', + coverImageSrc: null, + status: 'draft', + updatedAt: '2026-04-25T10:05:00.000Z', + publishReady: false, + levelCount: 8, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + }, + ], + }); + vi.mocked(listPuzzleWorks).mockResolvedValue({ + items: [ + { + workId: 'puzzle-logout-cache-1', + profileId: 'puzzle-logout-cache-profile', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-logout-cache-session', + authorDisplayName: '测试玩家', + levelName: '拼图退出缓存作品', + summary: '这条拼图私有作品只能在登录态展示。', + themeTags: ['退出态'], + coverImageSrc: null, + publicationStatus: 'draft', + updatedAt: '2026-04-25T10:10:00.000Z', + publishedAt: null, + playCount: 0, + publishReady: false, + }, + ], + }); + + const { rerender } = render(); + + await openCreationHub(user); + const createPanel = getPlatformTabPanel('create'); + + expect(await within(createPanel).findByText('RPG 退出缓存作品')).toBeTruthy(); + expect(await within(createPanel).findByText('大鱼退出缓存作品')).toBeTruthy(); + expect(await within(createPanel).findByText('拼图退出缓存作品')).toBeTruthy(); + + rerender(); + + await waitFor(() => { + expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull(); + expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull(); + expect(within(createPanel).queryByText('拼图退出缓存作品')).toBeNull(); + }); + expect(within(createPanel).getByText('还没有作品')).toBeTruthy(); +}); + test('published puzzle works appear on home and category public shelves', async () => { const user = userEvent.setup(); const publishedPuzzleWork = {