From 7c8aa1e12445807bca1e274f16a8603f9efbd81d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Sat, 9 May 2026 19:56:03 +0800 Subject: [PATCH] 1 --- .../scripts/generate-template-samples.mjs | 10 ++ .hermes/shared-memory/pitfalls.md | 24 +++ ...RE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md | 42 +++++ docs/technical/README.md | 1 + src/components/auth/AuthGate.test.tsx | 30 ++++ src/components/auth/AuthGate.tsx | 83 ++++++++-- .../PlatformEntryFlowShellImpl.tsx | 94 +++++++---- ...gEntryFlowShell.agent.interaction.test.tsx | 39 +++++ .../RpgEntryHomeView.recharge.test.tsx | 155 +++++++++++++++++- src/components/rpg-entry/RpgEntryHomeView.tsx | 32 ++-- src/services/apiClient.test.ts | 22 +++ src/services/apiClient.ts | 10 +- 12 files changed, 483 insertions(+), 59 deletions(-) create mode 100644 docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs index 6bce81f2..f3a69aed 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs @@ -174,6 +174,11 @@ async function fetchJson(url, options, timeoutMs) { throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`); } return JSON.parse(text); + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + } + throw error; } finally { clearTimeout(timer); } @@ -194,6 +199,11 @@ async function downloadUrl(url, timeoutMs) { response.headers.get('content-type') || 'image/jpeg', ), }; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`Generated image download timed out after ${timeoutMs}ms`); + } + throw error; } finally { clearTimeout(timer); } diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index ee0b4314..9e5dc4e1 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -151,6 +151,14 @@ - 验证:`npm run test -- src/components/auth/AuthGate.test.tsx`,新增用例应覆盖“旧 guest hydrate 不覆盖新登录态”。 - 关联:`src/components/auth/AuthGate.tsx`、`src/components/auth/AuthGate.test.tsx`、`docs/technical/AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md`。 +## 刷新网页后登录态失效 + +- 现象:刷新网页后,用户明明有本地 access token,却回到未登录状态。 +- 原因:`AuthGate` hydrate 曾先强制调用 `refreshStoredAccessToken()`;当 refresh cookie 临时失效、代理错配或后端返回 `401` 时,该方法会先清空本地 access token,随后 `/api/auth/me` 只能恢复成未登录。 +- 处理:`refreshStoredAccessToken()` 增加 `clearOnFailure` 选项;`AuthGate` 在已有本地 access token 时先用 `/api/auth/me` 确认用户,确认成功后再后台 refresh 续期与写每日登录埋点,后台 refresh 失败不清 token。 +- 验证:`npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx -t "explicit refresh opts out|auth gate keeps a valid local token login"`。 +- 关联:`src/services/apiClient.ts`、`src/components/auth/AuthGate.tsx`、`docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`。 + ## 登录后推荐页加载出作品又回到未登录 - 现象:前端登录成功后进入推荐页,推荐页自动加载出一个作品,随后瞬间回到未登录;停留在其他页面或推荐页没加载出作品时不复现。 @@ -162,6 +170,22 @@ - 验证:`npm run test -- src/services/apiClient.test.ts src/services/assetReadUrlService.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle runtime uses frontend move merge logic and backend leaderboard"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle similar work keeps current run level progression"`。 - 关联:`src/services/apiClient.ts`、`src/services/assetReadUrlService.ts`、`src/services/puzzle-runtime/puzzleRuntimeClient.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`。 +## 推荐页作品卡一直显示加载中 + +- 现象:推荐页有公开作品,但主视口一直停在“加载中...”,没有进入作品,也没有显示可操作错误。 +- 原因:推荐页自动启动嵌入运行态时先设置 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,但失败或并发切换时外层缺少稳定错误态和请求版本保护,旧启动请求可能晚到覆盖新状态。 +- 处理:`selectRecommendRuntimeEntry` 使用启动请求版本号丢弃旧请求;启动失败统一设置 `activeRecommendRuntimeError = "作品暂时无法进入,请稍后再试。"` 并关闭 `isStartingRecommendEntry`。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation surfaces start failure"`。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`。 + +## 推荐页未登录入口误打开公开详情 + +- 现象:新用户默认在发现页,但点击推荐页或推荐封面后,如果复用公开作品详情入口,可能绕过推荐页“登录后游玩”的产品门禁。 +- 原因:`RpgEntryHomeView` 曾只有 `onOpenGalleryDetail` 一个回调,同时服务发现页公开详情和推荐页作品入口;一旦为发现页保留公开浏览能力,推荐页也会跟着打开详情。 +- 处理:公开详情与推荐页入口分离为 `onOpenGalleryDetail` 和 `onOpenRecommendGalleryDetail`。发现页、搜索和排行榜保留公开详情;推荐 Tab、推荐封面、推荐运行态错误重试和桌面推荐模块统一走登录门禁。未登录推荐页只显示封面,点击封面只弹登录窗,不携带登录后自动打开详情的回调。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged out recommend"`。 +- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`。 + ## Rust 冷编译导致 api-server 健康检查误超时 - 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`。 diff --git a/docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md b/docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md new file mode 100644 index 00000000..3ed172b2 --- /dev/null +++ b/docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md @@ -0,0 +1,42 @@ +# 登录恢复与推荐页加载态收口修复 + +日期:`2026-05-09` + +## 背景 + +推荐页作品卡偶发一直停留在“加载中...”,同时刷新网页后可能从已登录态回到未登录态。两类问题都发生在平台入口首屏和登录态恢复链路中,表现上像推荐页问题,实际涉及 `AuthGate`、请求层 token 处理和推荐页嵌入运行态的启动状态机。 + +## 根因 + +刷新网页掉线的关键根因是 `AuthGate` hydrate 先强制调用 `refreshStoredAccessToken()`。如果 refresh cookie 临时不可用、代理错配或后端短暂返回 `401`,该方法会清掉本地 access token,随后 `/api/auth/me` 只能恢复成未登录。 + +推荐页作品卡卡住加载中的根因是推荐页自动启动运行态时,`activeRecommendEntryKey` 和 `activeRecommendRuntimeKind` 先被设置,失败时只把 kind 置空或由玩法内部写错误;外层没有稳定的 `activeRecommendRuntimeError` 收口。并发切换作品时,旧启动请求也可能晚到覆盖新启动状态。 + +## 修复 + +1. `refreshStoredAccessToken()` 增加 `clearOnFailure` 选项,默认保持原全局恢复语义;显式传 `false` 时 refresh 失败不会清空现有 access token。 +2. `AuthGate` 已有本地 access token 时,先用 `/api/auth/me` 确认当前用户;确认成功后再后台调用 refresh 续期与写每日登录埋点。后台 refresh 失败只静默忽略,不再把已确认账号改成未登录。 +3. 本地没有 access token 时,`AuthGate` 仍通过 refresh cookie 补票;该路径失败会清 token 并落到未登录,保持原有安全语义。 +4. 推荐页 `selectRecommendRuntimeEntry` 增加启动请求版本号。旧请求晚到后直接丢弃,不能覆盖当前作品。 +5. 推荐页运行态启动失败时统一写入 `activeRecommendRuntimeError = "作品暂时无法进入,请稍后再试。"`,并关闭 `isStartingRecommendEntry`,避免作品卡永久显示加载态。 +6. 推荐页入口继续保持登录门禁。未登录用户点击推荐 Tab 只切到推荐封面并弹出登录弹窗;未登录状态下点击推荐封面再次弹出登录弹窗,不打开详情、不启动运行态。 +7. `RpgEntryHomeView` 将公开作品详情入口与推荐页入口拆成 `onOpenGalleryDetail` 和 `onOpenRecommendGalleryDetail`:发现页、搜索结果和排行榜仍可按公开浏览能力打开详情;推荐页封面、推荐运行态错误重试、桌面推荐模块统一走推荐门禁入口。 + +## 验证 + +1. `npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "explicit refresh opts out|auth gate keeps a valid local token login|home recommendation"` +2. 后续完整收口仍建议执行: + - `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/components/auth/AuthGate.test.tsx src/services/apiClient.test.ts` + - `npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` + - `npm run typecheck` + - `npm run check:encoding` + +## 关联文件 + +1. `src/services/apiClient.ts` +2. `src/components/auth/AuthGate.tsx` +3. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +4. `src/components/rpg-entry/RpgEntryHomeView.tsx` +5. `src/components/auth/AuthGate.test.tsx` +6. `src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` +7. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` diff --git a/docs/technical/README.md b/docs/technical/README.md index 91c427ee..0c9e7af0 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -7,6 +7,7 @@ - [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。 - [VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md](./VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md):记录 GPT-image-2 图片生成从 APIMart 迁移到 VectorEngine `gpt-image-2-all` 的接口、环境变量、尺寸映射、错误口径和验收命令。 - [SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md](./SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md):记录本地 `spacetime publish` 被 sccache wrapper 通信异常阻断时的根因、`dev-rust-stack` 自动降级策略和手动排障命令。 +- [AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md](./AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md):记录刷新网页后登录态失效和推荐页作品卡卡在加载中的联合修复,覆盖 `AuthGate` 本地 token 优先恢复、refresh 失败不清 token、推荐页启动请求版本保护和错误态收口。 - [RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md](./RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md):记录平台推荐页自动加载作品、公开拼图作品完整运行态、平台 bootstrap 私有投影刷新和展示层图片换签的局部请求 `401` 不应扩散成全局登出的修复,覆盖 `authImpact: local` 请求策略、推荐页 embedded 运行态启动、拼图开局/排行榜/下一关和回归测试。 - [AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md](./AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md):记录 `AuthGate` 登录成功后又被旧 hydrate 覆盖回未登录态的竞态根因、版本号保护修复与回归测试。 - [HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md](./HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md):记录 Hyper3D Rodin Gen-2 文生 3D 模型、图生 3D 模型、状态查询和下载列表的后端代理、环境变量、请求约束与验收边界。 diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index e5ae9b62..a508208e 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -13,6 +13,7 @@ const authMocks = vi.hoisted(() => ({ authEntry: vi.fn(), changePassword: vi.fn(), ensureStoredAccessToken: vi.fn(), + getStoredAccessToken: vi.fn(), refreshStoredAccessToken: vi.fn(), getAuthLoginOptions: vi.fn(), getCurrentAuthUser: vi.fn(), @@ -29,6 +30,7 @@ const authMocks = vi.hoisted(() => ({ vi.mock('../../services/apiClient', () => ({ AUTH_STATE_EVENT: 'genarrative-auth-state-changed', ensureStoredAccessToken: authMocks.ensureStoredAccessToken, + getStoredAccessToken: authMocks.getStoredAccessToken, refreshStoredAccessToken: authMocks.refreshStoredAccessToken, })); @@ -96,6 +98,7 @@ beforeEach(() => { window.history.replaceState(null, '', '/'); authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token'); + authMocks.getStoredAccessToken.mockReturnValue(''); authMocks.refreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token'); authMocks.getCurrentAuthUser.mockResolvedValue({ user: null, @@ -231,10 +234,37 @@ test('auth gate waits for refresh cookie rotation before exposing restored user expect(await screen.findByText('应用内容')).toBeTruthy(); expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledTimes(1); + expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({ + clearOnFailure: true, + }); expect(authMocks.ensureStoredAccessToken).not.toHaveBeenCalled(); expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); }); +test('auth gate keeps a valid local token login when refresh rotation fails after reload', async () => { + authMocks.getStoredAccessToken.mockReturnValue('jwt-existing-token'); + authMocks.refreshStoredAccessToken.mockRejectedValue( + new Error('refresh cookie 失效'), + ); + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: mockUser, + availableLoginMethods: ['phone'], + }); + + render( + + + , + ); + + expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy(); + expect(screen.getByText('私有数据:可读取')).toBeTruthy(); + expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({ + clearOnFailure: false, + }); + expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); +}); + test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => { authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: [], diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index b009dfa1..a7db8e38 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -10,6 +10,7 @@ import { import { useGameSettings } from '../../hooks/useGameSettings'; import { AUTH_STATE_EVENT, + getStoredAccessToken, refreshStoredAccessToken, } from '../../services/apiClient'; import { @@ -18,6 +19,7 @@ import { authEntry, type AuthLoginMethod, type AuthRiskBlockSummary, + type AuthSessionSnapshot, type AuthSessionSummary, type AuthUser, bindWechatPhone, @@ -81,6 +83,18 @@ function normalizeAvailableLoginMethods( : FALLBACK_LOGIN_METHODS; } +type AuthHydrateSessionResult = + | { + kind: 'authenticated'; + session: AuthSessionSnapshot & { + user: AuthUser; + }; + } + | { + kind: 'guest'; + session: AuthSessionSnapshot | null; + }; + export function AuthGate({ children }: AuthGateProps) { const [status, setStatus] = useState('checking'); const [user, setUser] = useState(null); @@ -163,6 +177,56 @@ export function AuthGate({ children }: AuthGateProps) { setError(''); }, []); + const restoreAuthSession = useCallback(async () => { + const hadLocalAccessToken = Boolean(getStoredAccessToken()); + + if (hadLocalAccessToken) { + try { + const session = await getCurrentAuthUser(); + if (session.user) { + const confirmedUser = session.user; + // 中文注释:已有 access token 能确认当前账号时,refresh 只作为续期和每日登录埋点补强。 + // refresh cookie 临时失效或代理抖动不能反向抹掉这次已确认的登录态。 + void refreshStoredAccessToken({ clearOnFailure: false }).catch( + () => undefined, + ); + return { + kind: 'authenticated', + session: { + ...session, + user: confirmedUser, + }, + } satisfies AuthHydrateSessionResult; + } + + return { + kind: 'guest', + session, + } satisfies AuthHydrateSessionResult; + } catch { + // 本地 token 可能已过期或被吊销,再尝试通过 refresh cookie 补票。 + } + } + + await refreshStoredAccessToken({ clearOnFailure: true }); + const session = await getCurrentAuthUser(); + if (session.user) { + const confirmedUser = session.user; + return { + kind: 'authenticated', + session: { + ...session, + user: confirmedUser, + }, + } satisfies AuthHydrateSessionResult; + } + + return { + kind: 'guest', + session, + } satisfies AuthHydrateSessionResult; + }, []); + const logoutCurrentSession = useCallback(async () => { clearLocalAuthenticatedState(); try { @@ -316,26 +380,21 @@ export function AuthGate({ children }: AuthGateProps) { } try { - // 中文注释:打开已登录页面也要主动轮换 refresh cookie。 - // 后端只在 refresh/session 成功续期时写每日登录埋点;如果本地 access token 尚未过期, - // 仅调用 /auth/me 不会进入续期链路,导致“打开网页”没有登录埋点。 - await refreshStoredAccessToken(); + const restoredSession = await restoreAuthSession(); if (!isCurrentHydrate()) { return; } - const nextSession = await getCurrentAuthUser(); - if (!isCurrentHydrate()) { - return; - } - - if (!nextSession.user) { + if (restoredSession.kind === 'guest') { setAvailableLoginMethods( - normalizeAvailableLoginMethods(nextSession.availableLoginMethods), + normalizeAvailableLoginMethods( + restoredSession.session?.availableLoginMethods, + ), ); await resolveGuestFallback(); return; } + const nextSession = restoredSession.session; setUser(nextSession.user); setAvailableLoginMethods( normalizeAvailableLoginMethods(nextSession.availableLoginMethods), @@ -368,7 +427,7 @@ export function AuthGate({ children }: AuthGateProps) { isActive = false; window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange); }; - }, [activateReadyUser]); + }, [restoreAuthSession]); useEffect(() => { if (!readyUser) { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index cc895dcb..535c7ae9 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1558,6 +1558,7 @@ export function PlatformEntryFlowShellImpl({ useState(null); const [isStartingRecommendEntry, setIsStartingRecommendEntry] = useState(false); + const recommendRuntimeStartRequestRef = useRef(0); const [, setPuzzleOperation] = useState( null, ); @@ -5651,38 +5652,36 @@ export function PlatformEntryFlowShellImpl({ ], ); - const openRecommendGalleryDetail = useCallback( + const openPublicGalleryDetail = useCallback( (entry: PlatformPublicGalleryCard) => { - runProtectedAction(() => { - if (isBigFishGalleryEntry(entry)) { - openPublicWorkDetail(entry); - return; - } + if (isBigFishGalleryEntry(entry)) { + openPublicWorkDetail(entry); + return; + } - if (isPuzzleGalleryEntry(entry)) { - void openPuzzlePublicWorkDetail(entry.profileId, { - tab: platformBootstrap.platformTab, - }); - return; - } + if (isPuzzleGalleryEntry(entry)) { + void openPuzzlePublicWorkDetail(entry.profileId, { + tab: platformBootstrap.platformTab, + }); + return; + } - if (isMatch3DGalleryEntry(entry)) { - openPublicWorkDetail(entry); - return; - } + if (isMatch3DGalleryEntry(entry)) { + openPublicWorkDetail(entry); + return; + } - if (isSquareHoleGalleryEntry(entry)) { - openPublicWorkDetail(entry); - return; - } + if (isSquareHoleGalleryEntry(entry)) { + openPublicWorkDetail(entry); + return; + } - if (isVisualNovelGalleryEntry(entry)) { - void openVisualNovelPublicWorkDetail(entry.profileId); - return; - } + if (isVisualNovelGalleryEntry(entry)) { + void openVisualNovelPublicWorkDetail(entry.profileId); + return; + } - void openRpgPublicWorkDetail(entry); - }); + void openRpgPublicWorkDetail(entry); }, [ openPuzzlePublicWorkDetail, @@ -5690,9 +5689,17 @@ export function PlatformEntryFlowShellImpl({ openRpgPublicWorkDetail, openVisualNovelPublicWorkDetail, platformBootstrap.platformTab, - runProtectedAction, ], ); + + const openRecommendGalleryDetail = useCallback( + (entry: PlatformPublicGalleryCard) => { + runProtectedAction(() => { + openPublicGalleryDetail(entry); + }); + }, + [openPublicGalleryDetail, runProtectedAction], + ); const openPuzzleDetail = useCallback( async ( profileId: string, @@ -6112,8 +6119,15 @@ export function PlatformEntryFlowShellImpl({ async (entry: PlatformPublicGalleryCard) => { const entryKey = getPlatformPublicGalleryEntryKey(entry); const runtimeKind = getPlatformRecommendRuntimeKind(entry); + const startRequestId = recommendRuntimeStartRequestRef.current + 1; + recommendRuntimeStartRequestRef.current = startRequestId; + const isCurrentStartRequest = () => + recommendRuntimeStartRequestRef.current === startRequestId; if (entryKey !== activeRecommendEntryKey) { await saveAndExitRecommendPuzzleRuntime(); + if (!isCurrentStartRequest()) { + return; + } } setActiveRecommendEntryKey(entryKey); setActiveRecommendRuntimeKind(runtimeKind); @@ -6182,9 +6196,28 @@ export function PlatformEntryFlowShellImpl({ started = true; } - setActiveRecommendRuntimeKind(started ? runtimeKind : null); + if (!isCurrentStartRequest()) { + return; + } + + if (started) { + setActiveRecommendRuntimeKind(runtimeKind); + setActiveRecommendRuntimeError(null); + } else { + setActiveRecommendRuntimeKind(null); + setActiveRecommendRuntimeError('作品暂时无法进入,请稍后再试。'); + } + } catch (error) { + if (!isCurrentStartRequest()) { + return; + } + + setActiveRecommendRuntimeKind(null); + setActiveRecommendRuntimeError('作品暂时无法进入,请稍后再试。'); } finally { - setIsStartingRecommendEntry(false); + if (isCurrentStartRequest()) { + setIsStartingRecommendEntry(false); + } } }, [ @@ -7549,7 +7582,8 @@ export function PlatformEntryFlowShellImpl({ }} onOpenCreateWorld={openCreationTypePicker} onOpenCreateTypePicker={openCreationTypePicker} - onOpenGalleryDetail={openRecommendGalleryDetail} + onOpenGalleryDetail={openPublicGalleryDetail} + onOpenRecommendGalleryDetail={openRecommendGalleryDetail} recommendRuntimeContent={recommendRuntimeContent} activeRecommendEntryKey={activeRecommendEntryKey} isStartingRecommendEntry={ diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 5098fcf0..14397ddd 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -2810,6 +2810,7 @@ test('owned public puzzle detail edits original draft instead of remixing', asyn render(); + await openDiscoverHub(user); await waitFor(() => { expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0); }); @@ -3066,6 +3067,44 @@ test('home recommendation starts embedded puzzle without global auth reset on lo }); }); +test('home recommendation surfaces start failure instead of staying in loading state', async () => { + const publishedPuzzleWork = { + workId: 'puzzle-work-public-1', + profileId: 'puzzle-profile-public-1', + ownerUserId: 'user-2', + sourceSessionId: 'puzzle-session-public-1', + authorDisplayName: '拼图作者', + levelName: '星桥机关', + summary: '旋转碎片并接通星桥机关。', + themeTags: ['机关', '星桥'], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'published', + updatedAt: '2026-04-25T09:00:00.000Z', + publishedAt: '2026-04-25T09:00:00.000Z', + playCount: 3, + likeCount: 0, + publishReady: true, + } satisfies PuzzleWorkSummary; + + vi.mocked(listPuzzleGallery).mockResolvedValue({ + items: [publishedPuzzleWork], + }); + vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ + item: publishedPuzzleWork, + }); + vi.mocked(startPuzzleRun).mockRejectedValueOnce( + new Error('启动拼图玩法失败'), + ); + + render(); + + expect( + await screen.findByText('作品暂时无法进入,请稍后再试。'), + ).toBeTruthy(); + expect(screen.queryByText('加载中...')).toBeNull(); +}); + test('published big fish works stay hidden from platform home and game category channel', async () => { const user = userEvent.setup(); const publishedBigFishWork: BigFishWorkSummary = { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index aef47b45..9187710f 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -515,6 +515,7 @@ function renderLoggedOutHomeView( | 'featuredEntries' | 'latestEntries' | 'onOpenGalleryDetail' + | 'onOpenRecommendGalleryDetail' | 'onSearchPublicCode' | 'recommendRuntimeContent' | 'activeRecommendEntryKey' @@ -568,6 +569,7 @@ function renderLoggedOutHomeView( onOpenCreateWorld={vi.fn()} onOpenCreateTypePicker={vi.fn()} onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()} + onOpenRecommendGalleryDetail={overrides.onOpenRecommendGalleryDetail} recommendRuntimeContent={ overrides.recommendRuntimeContent ?? (
运行内容
@@ -592,6 +594,7 @@ function renderStatefulLoggedOutHomeView( | 'featuredEntries' | 'latestEntries' | 'onOpenGalleryDetail' + | 'onOpenRecommendGalleryDetail' | 'onSearchPublicCode' | 'recommendRuntimeContent' | 'activeRecommendEntryKey' @@ -650,6 +653,7 @@ function renderStatefulLoggedOutHomeView( onOpenCreateWorld={vi.fn()} onOpenCreateTypePicker={vi.fn()} onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()} + onOpenRecommendGalleryDetail={overrides.onOpenRecommendGalleryDetail} recommendRuntimeContent={ overrides.recommendRuntimeContent ?? (
@@ -1171,7 +1175,93 @@ test('logged out recommend cover opens login modal again', async () => { await user.click(screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u })); expect(openLoginModal).toHaveBeenCalledTimes(2); - expect(openLoginModal).toHaveBeenLastCalledWith(expect.any(Function)); + expect(openLoginModal).toHaveBeenLastCalledWith(); + expect(onOpenGalleryDetail).not.toHaveBeenCalled(); +}); + +test('logged out desktop recommend page renders cover only', () => { + mockDesktopLayout(); + renderLoggedOutHomeView(vi.fn(), { + latestEntries: [puzzlePublicEntry], + activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', + }); + + expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy(); + expect(screen.queryByText('今日游戏')).toBeNull(); + expect(screen.queryByText('作品分类')).toBeNull(); + expect(screen.queryByTestId('recommend-runtime')).toBeNull(); +}); + +test('logged in recommend page uses gated recommend detail callback', async () => { + const user = userEvent.setup(); + const onOpenGalleryDetail = vi.fn(); + const onOpenRecommendGalleryDetail = vi.fn(); + + render( + action(), + openSettingsModal: vi.fn(), + openAccountModal: vi.fn(), + setCurrentUser: vi.fn(), + logout: vi.fn(async () => undefined), + musicVolume: 0.42, + setMusicVolume: vi.fn(), + platformTheme: 'light', + setPlatformTheme: vi.fn(), + isHydratingSettings: false, + isPersistingSettings: false, + settingsError: null, + }} + > + + , + ); + + await user.click(screen.getByText('作品暂时无法进入,请稍后再试。')); + + expect(onOpenRecommendGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry); expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); @@ -1435,7 +1525,7 @@ test('mobile today channel only shows newly published works from today', async ( expect(within(discoverPanel).queryByText('今日更新旧作')).toBeNull(); }); -test('desktop home syncs mobile home modules without square or latest labels', () => { +test('desktop logged in home syncs mobile home modules without square or latest labels', () => { mockDesktopLayout(); const todayPublishedAt = new Date().toISOString(); const todayEntry = { @@ -1448,9 +1538,64 @@ test('desktop home syncs mobile home modules without square or latest labels', ( updatedAt: todayPublishedAt, } satisfies PlatformPublicGalleryCard; - renderLoggedOutHomeView(vi.fn(), { - latestEntries: [puzzlePublicEntry, todayEntry], - }); + render( + action(), + openSettingsModal: vi.fn(), + openAccountModal: vi.fn(), + setCurrentUser: vi.fn(), + logout: vi.fn(async () => undefined), + musicVolume: 0.42, + setMusicVolume: vi.fn(), + platformTheme: 'light', + setPlatformTheme: vi.fn(), + isHydratingSettings: false, + isPersistingSettings: false, + settingsError: null, + }} + > + + , + ); expect(screen.getByText('今日游戏')).toBeTruthy(); expect(screen.getAllByText('推荐').length).toBeGreaterThan(0); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 2ad471b1..21624603 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -118,6 +118,7 @@ export interface RpgEntryHomeViewProps { onOpenCreateWorld: () => void; onOpenCreateTypePicker: () => void; onOpenGalleryDetail: (entry: PlatformPublicGalleryCard) => void; + onOpenRecommendGalleryDetail?: (entry: PlatformPublicGalleryCard) => void; recommendRuntimeContent?: ReactNode; activeRecommendEntryKey?: string | null; isStartingRecommendEntry?: boolean; @@ -2898,6 +2899,7 @@ export function RpgEntryHomeView({ onResumeSave, onOpenCreateTypePicker, onOpenGalleryDetail, + onOpenRecommendGalleryDetail, recommendRuntimeContent, activeRecommendEntryKey = null, isStartingRecommendEntry = false, @@ -3007,6 +3009,8 @@ export function RpgEntryHomeView({ const [isSavingAvatar, setIsSavingAvatar] = useState(false); const isAuthenticated = Boolean(authUi?.user); const isDesktopLayout = usePlatformDesktopLayout(); + const openRecommendGalleryDetail = + onOpenRecommendGalleryDetail ?? onOpenGalleryDetail; const featuredShelf = useMemo( () => featuredEntries.slice(0, 6), [featuredEntries], @@ -3771,12 +3775,17 @@ export function RpgEntryHomeView({ } if (!isAuthenticated) { - authUi?.openLoginModal(() => onOpenGalleryDetail(activeRecommendEntry)); + authUi?.openLoginModal(); return; } - onOpenGalleryDetail(activeRecommendEntry); - }, [activeRecommendEntry, authUi, isAuthenticated, onOpenGalleryDetail]); + openRecommendGalleryDetail(activeRecommendEntry); + }, [ + activeRecommendEntry, + authUi, + isAuthenticated, + openRecommendGalleryDetail, + ]); const selectNextRecommendEntry = useCallback(() => { onSelectNextRecommendEntry?.(); }, [onSelectNextRecommendEntry]); @@ -3786,7 +3795,7 @@ export function RpgEntryHomeView({ const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null; const openLeadPublicEntry = () => { if (leadPublicEntry) { - onOpenGalleryDetail(leadPublicEntry); + openRecommendGalleryDetail(leadPublicEntry); return; } @@ -3870,7 +3879,7 @@ export function RpgEntryHomeView({ type="button" onClick={() => activeRecommendEntry - ? onOpenGalleryDetail(activeRecommendEntry) + ? openRecommendGalleryDetail(activeRecommendEntry) : undefined } className="platform-recommend-runtime-state platform-recommend-runtime-state--button" @@ -4463,7 +4472,7 @@ export function RpgEntryHomeView({ key={`${buildPublicGalleryCardKey(entry)}:desktop-today`} entry={entry} rank={index + 1} - onClick={() => onOpenGalleryDetail(entry)} + onClick={() => openRecommendGalleryDetail(entry)} /> ))}
@@ -4486,7 +4495,7 @@ export function RpgEntryHomeView({ onOpenGalleryDetail(entry)} + onClick={() => openRecommendGalleryDetail(entry)} className="w-full min-w-0" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} /> @@ -4552,7 +4561,7 @@ export function RpgEntryHomeView({ key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`} type="button" onClick={() => - onOpenGalleryDetail({ + openRecommendGalleryDetail({ ownerUserId: entry.ownerUserId, profileId: entry.profileId, publicWorkCode: null, @@ -4621,7 +4630,7 @@ export function RpgEntryHomeView({ onOpenGalleryDetail(entry)} + onClick={() => openRecommendGalleryDetail(entry)} className="w-full min-w-0" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} /> @@ -4638,7 +4647,10 @@ export function RpgEntryHomeView({ ); const tabContentById = { - home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent, + home: + !isAuthenticated || !isDesktopLayout + ? mobileRecommendContent + : desktopHomeContent, category: categoryContent, create: createContent, saves: savesContent, diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index 38b73a13..b1920e8b 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -8,6 +8,7 @@ import { fetchWithApiAuth, getStoredAccessToken, isTimeoutError, + refreshStoredAccessToken, requestJson, setStoredAccessToken, } from './apiClient'; @@ -312,6 +313,27 @@ describe('apiClient', () => { expect(getStoredAccessToken()).toBe('still-valid-token'); }); + it('keeps local token when explicit refresh opts out of clearing on failure', async () => { + setStoredAccessToken('usable-local-token', { emit: false }); + fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); + + await expect( + refreshStoredAccessToken({ clearOnFailure: false }), + ).rejects.toMatchObject({ + status: 401, + }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/auth/refresh', + expect.objectContaining({ + method: 'POST', + credentials: 'same-origin', + }), + ); + expect(dispatchEventMock).not.toHaveBeenCalled(); + expect(getStoredAccessToken()).toBe('usable-local-token'); + }); + it('keeps the refreshed token when the retried protected request is still unauthorized', async () => { setStoredAccessToken('expired-token', { emit: false }); fetchMock diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index afac88c9..0825dc17 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -548,11 +548,17 @@ export async function ensureStoredAccessToken() { return refreshAccessToken(); } -export async function refreshStoredAccessToken() { +export async function refreshStoredAccessToken( + options: { + clearOnFailure?: boolean; + } = {}, +) { try { return await refreshAccessToken(); } catch (error) { - clearStoredAccessToken({ emit: false }); + if (options.clearOnFailure !== false) { + clearStoredAccessToken({ emit: false }); + } throw error; } }