fix: clear creation hub cache on logout
This commit is contained in:
@@ -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. 重新登录后按新账号重新拉取作品列表,不复用旧账号内存缓存。
|
||||
@@ -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 的展示字段、筛选状态和卡片动作语义,不新增后端聚合接口。
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div>当前用户:{authUi?.user?.displayName ?? '未登录'}</div>
|
||||
<div>
|
||||
私有数据:{authUi?.canAccessProtectedData ? '可读取' : '不可读取'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void authUi?.logout();
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<void>((resolve) => {
|
||||
resolveLogout = resolve;
|
||||
});
|
||||
authMocks.logoutAuthUser.mockReturnValueOnce(logoutPromise);
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<LogoutStateProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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(<TestWrapper authValue={loggedInAuth} />);
|
||||
|
||||
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(<TestWrapper authValue={loggedOutAuth} />);
|
||||
|
||||
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 = {
|
||||
|
||||
Reference in New Issue
Block a user