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