Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-21 09:44:25 +08:00
5 changed files with 103 additions and 13 deletions

View File

@@ -149,8 +149,15 @@
- 不主动附带 `Authorization` - 不主动附带 `Authorization`
- 不因本地 access token 失效去触发 `/api/auth/refresh` - 不因本地 access token 失效去触发 `/api/auth/refresh`
- 若当前请求本身没有携带 access token也不允许因为返回 `401` 就额外触发 `/api/auth/refresh`
- refresh cookie 缺失、refresh 失败、账号状态过期时,不能把首页公开作品广场一起拖成错误态 - refresh cookie 缺失、refresh 失败、账号状态过期时,不能把首页公开作品广场一起拖成错误态
受保护工作区恢复补充约束:
- 若 URL 或 `sessionStorage` 中残留 `customWorldSessionId``customWorldOperationId` 等共创工作区恢复标记,未登录态下不能直接请求对应的 Agent 会话或操作接口
- 这类“恢复共创工作区”场景要先弹出登录弹窗,登录成功后再恢复原本的工作区或操作上下文
- 用户未登录且关闭登录弹窗时,前端保持平台页可浏览状态,不允许持续轮询受保护接口
未登录时不读取: 未登录时不读取:
- 自定义世界库 - 自定义世界库

View File

@@ -387,7 +387,7 @@ beforeEach(() => {
test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => { test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<TestWrapper />); render(<TestWrapper withAuth />);
await openCreationHub(user); await openCreationHub(user);
const createButtons = await screen.findAllByRole('button', { const createButtons = await screen.findAllByRole('button', {
@@ -522,10 +522,37 @@ test('selecting RPG creation while logged out routes through requireAuth', async
expect(createCustomWorldAgentSession).not.toHaveBeenCalled(); 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(
<TestWrapper
authValue={createAuthValue({
user: null,
openLoginModal,
requireAuth: vi.fn(),
})}
/>,
);
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 () => { test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<TestWrapper />); render(<TestWrapper withAuth />);
await openNewRpgCreation(user); await openNewRpgCreation(user);
@@ -657,7 +684,7 @@ test('existing draft sessions enter the agent preview layout without opening leg
], ],
}); });
render(<TestWrapper />); render(<TestWrapper withAuth />);
await openNewRpgCreation(user); await openNewRpgCreation(user);

View File

@@ -228,6 +228,7 @@ export function PreGameSelectionFlow({
const authUi = useAuthUi(); const authUi = useAuthUi();
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState()); const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
const hasAppliedInitialAgentWorkspaceRef = useRef(false); const hasAppliedInitialAgentWorkspaceRef = useRef(false);
const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false);
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] = const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
useState<CustomWorldProfile | null>(null); useState<CustomWorldProfile | null>(null);
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState< const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
@@ -400,16 +401,29 @@ export function PreGameSelectionFlow({
); );
useEffect(() => { 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; return;
} }
hasAppliedInitialAgentWorkspaceRef.current = true; hasAppliedInitialAgentWorkspaceRef.current = true;
if (initialAgentUiStateRef.current.activeSessionId) { setSelectionStage('agent-workspace');
setPlatformTab('create'); }, [authUi?.openLoginModal, authUi?.user, setSelectionStage]);
setSelectionStage('agent-workspace');
}
}, [setSelectionStage]);
useEffect(() => { useEffect(() => {
if (!selectedDetailEntry) { if (!selectedDetailEntry) {
@@ -602,6 +616,16 @@ export function PreGameSelectionFlow({
useEffect(() => { useEffect(() => {
if (!activeAgentSessionId) { if (!activeAgentSessionId) {
setAgentSession(null); setAgentSession(null);
setAgentOperation(null);
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
return;
}
if (!authUi?.user) {
setAgentSession(null);
setAgentOperation(null);
setIsLoadingAgentSession(false); setIsLoadingAgentSession(false);
setStreamingAgentReplyText(''); setStreamingAgentReplyText('');
setIsStreamingAgentReply(false); setIsStreamingAgentReply(false);
@@ -644,13 +668,14 @@ export function PreGameSelectionFlow({
}; };
}, [ }, [
activeAgentSessionId, activeAgentSessionId,
authUi?.user,
persistAgentUiState, persistAgentUiState,
setSelectionStage, setSelectionStage,
syncAgentSessionSnapshot, syncAgentSessionSnapshot,
]); ]);
useEffect(() => { useEffect(() => {
if (!activeAgentSessionId || !activeAgentOperationId) { if (!activeAgentSessionId || !activeAgentOperationId || !authUi?.user) {
return; return;
} }
@@ -710,6 +735,7 @@ export function PreGameSelectionFlow({
}, [ }, [
activeAgentOperationId, activeAgentOperationId,
activeAgentSessionId, activeAgentSessionId,
authUi?.user,
persistAgentUiState, persistAgentUiState,
syncAgentSessionSnapshot, syncAgentSessionSnapshot,
]); ]);

View File

@@ -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 () => { it('retries transient get requests before unwrapping the response envelope', async () => {
fetchMock fetchMock
.mockRejectedValueOnce(new TypeError('network unavailable')) .mockRejectedValueOnce(new TypeError('network unavailable'))

View File

@@ -349,12 +349,15 @@ export function setStoredAccessToken(
} }
const nextToken = token.trim(); const nextToken = token.trim();
const previousToken = getStoredAccessToken();
if (nextToken) { if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken); window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken);
} else { } else {
window.localStorage.removeItem(ACCESS_TOKEN_KEY); window.localStorage.removeItem(ACCESS_TOKEN_KEY);
} }
if (options.emit !== false) {
// 只有登录态令牌真的发生变化时才广播事件,避免无意义的鉴权重算。
if (options.emit !== false && previousToken !== nextToken) {
emitAuthStateChange(); emitAuthStateChange();
} }
} }
@@ -368,8 +371,11 @@ export function clearStoredAccessToken(
return; return;
} }
const previousToken = getStoredAccessToken();
window.localStorage.removeItem(ACCESS_TOKEN_KEY); window.localStorage.removeItem(ACCESS_TOKEN_KEY);
if (options.emit !== false) {
// 未登录态下重复清空 token 不应触发状态刷新,否则会放大 401 循环。
if (options.emit !== false && previousToken) {
emitAuthStateChange(); emitAuthStateChange();
} }
} }
@@ -445,14 +451,20 @@ export async function fetchWithApiAuth(
for (;;) { for (;;) {
try { try {
const requestHeaders = withAuthorizationHeaders(init.headers, options);
const hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
const response = await fetch(input, { const response = await fetch(input, {
credentials: 'same-origin', credentials: 'same-origin',
...init, ...init,
headers: withAuthorizationHeaders(init.headers, options), headers: requestHeaders,
}); });
if ( if (
response.status === 401 && response.status === 401 &&
hasAuthHeader &&
!options.skipAuth && !options.skipAuth &&
!options.skipRefresh && !options.skipRefresh &&
!refreshAttempted !refreshAttempted