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 = {