From 62a24bf0e5c76ced80c14ce3da17424671ebaeba Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 25 May 2026 22:45:29 +0800 Subject: [PATCH] fix: keep recommend runtime on local auth --- .hermes/shared-memory/decision-log.md | 2 +- .hermes/shared-memory/pitfalls.md | 5 ++-- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- .../PlatformEntryFlowShellImpl.tsx | 9 ++++++- ...gEntryFlowShell.agent.interaction.test.tsx | 27 ++++++++++++------- .../RpgEntryHomeView.recharge.test.tsx | 15 ++++++++--- 6 files changed, 43 insertions(+), 17 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 77ff4c31..b40920eb 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -19,7 +19,7 @@ ## 2026-05-25 平台首页推荐按桌面与移动断点分流 - 背景:平台首页的推荐页在桌面与移动端之间原先共用同一套推荐运行态逻辑,容易让桌面和移动两套内容同时启动,也让首页的推荐卡与桌面发现壳互相抢状态。 -- 决策:`RpgEntryHomeView` 只接受同一个 `isDesktopLayout` 断点判断;桌面端首页渲染桌面发现壳(`今日游戏`、`推荐`、`作品分类` 等),不挂移动推荐嵌入运行态;移动端 `home` 才渲染推荐卡与嵌入运行态。平台壳和首页视图都必须共用 `usePlatformDesktopLayout()`,不能在不同文件里各自判断断点。 +- 决策:`RpgEntryHomeView` 只接受同一个 `isDesktopLayout` 断点判断;桌面端首页渲染桌面发现壳(`今日游戏`、`推荐`、`作品分类` 等),不挂移动推荐嵌入运行态;移动端 `home` 才渲染推荐卡与嵌入运行态。平台壳和首页视图都必须共用 `usePlatformDesktopLayout()`,不能在不同文件里各自判断断点。推荐嵌入运行态不是登录门禁:未登录可直达匿名运行态;已登录或已有 access token 时继续使用账号 Bearer,但必须用 local auth impact 防止推荐卡 401 清空全局登录态。 - 影响范围:`src/components/platform-entry/platformEntryResponsive.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、首页推荐相关测试与 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - 验证方式:桌面宽度下首页应只看到桌面发现壳,窄屏下首页应只看到移动推荐流;`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation"`、`npm run typecheck`、`npm run check:encoding` 通过。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 0b84d15f..a36908cc 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -856,6 +856,7 @@ - 现象:前端登录成功后进入推荐页,推荐页自动加载出一个作品,随后瞬间回到未登录;停留在其他页面或推荐页没加载出作品时不复现。 - 原因:推荐页 embedded 运行态会自动发起受保护写请求。若这些卡片级后台请求遇到 `401` 或 refresh 失败,默认请求层曾清空 access token 并广播全局 auth 事件,导致 `AuthGate` 重新 hydrate 成未登录态。更隐蔽的是,`refreshAccessToken()` 自身曾在 refresh 失败时静默清 token,即便调用方关闭了 `clearAuthOnUnauthorized`,也可能让后续 hydrate 变成未登录。 - 处理:请求层统一使用 `authImpact: 'global' | 'local'` 区分账号权威请求与局部后台请求;推荐页自动运行态、图片换签、公开拼图运行态和平台 bootstrap 私有投影刷新统一使用 `BACKGROUND_AUTH_REQUEST_OPTIONS` / `RUNTIME_BACKGROUND_AUTH_OPTIONS`,并等 `canReadProtectedData` 为 true 后再启动;用户主动点击的账号动作仍保留默认全局鉴权失败处理。 +- 追加处理:推荐页嵌入运行态要按真实身份分流,已登录或已有 access token 时继续走账号 Bearer + local auth impact,不能误带 runtime guest token;只有匿名访客才申请并透传 runtime guest token。 - 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。 - 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`startPuzzleRun`、通关自动 `submitPuzzleLeaderboard`、下一关 `advancePuzzleNextLevel` 和重开同样属于当前玩法局部同步;这些请求失败时只应留在拼图错误态,不应清 token 或广播 auth 事件。 - 追加处理:通关后 `refreshSaveArchives()`、首屏 bootstrap 的个人看板/作品架/浏览历史读写也只是平台投影刷新,失败应显示局部错误,不能充当全局登录态判定。 @@ -872,9 +873,9 @@ ## 推荐页未登录入口误打开公开详情 -- 现象:新用户默认在发现页,但点击推荐页或推荐封面后,如果复用公开作品详情入口,可能绕过推荐页“登录后游玩”的产品门禁。 +- 现象:新用户默认在发现页,但点击推荐页或推荐封面后,如果复用公开作品详情入口,可能绕过推荐页沉浸运行态,打开普通公开详情页。 - 原因:`RpgEntryHomeView` 曾只有 `onOpenGalleryDetail` 一个回调,同时服务发现页公开详情和推荐页作品入口;一旦为发现页保留公开浏览能力,推荐页也会跟着打开详情。 -- 处理:公开详情与推荐页入口分离为 `onOpenGalleryDetail` 和 `onOpenRecommendGalleryDetail`。发现页、搜索和排行榜保留公开详情;推荐 Tab、推荐封面、推荐运行态错误重试和桌面推荐模块统一走登录门禁。未登录推荐页只显示封面,点击封面只弹登录窗,不携带登录后自动打开详情的回调。 +- 处理:公开详情与推荐页入口分离为 `onOpenGalleryDetail` 和 `onOpenRecommendGalleryDetail`。发现页、搜索和排行榜保留公开详情;推荐 Tab、推荐封面、推荐运行态错误重试和桌面推荐模块走推荐运行态入口,不再主动弹登录窗。登录门禁只保留给创作、个人作品、删除、发布、Remix 等账号或所有权动作。 - 验证:`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`。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 95051672..8bd8b334 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -133,7 +133,7 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `次级入口 > 删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。 -推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时统一先申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续透传 runtime guest token;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 +推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 ## 敲木鱼 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index db40be58..e258cec0 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -554,6 +554,8 @@ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([ ]); const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS = BACKGROUND_AUTH_REQUEST_OPTIONS; +const RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS = + RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS; async function buildRecommendRuntimeGuestOptions() { const { token } = await ensureRuntimeGuestToken(); return { @@ -8677,10 +8679,15 @@ export function PlatformEntryFlowShellImpl({ ? await buildRecommendRuntimeGuestOptions() : {}; const authMode = useRuntimeGuestAuth ? 'isolated' : 'default'; + const runtimeAuthOptions = useRuntimeGuestAuth + ? runtimeGuestOptions + : canUseRuntimeGuestAuth + ? RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS + : {}; const { run } = authMode === 'isolated' ? await startPuzzleRun(startRunPayload, runtimeGuestOptions) - : await startPuzzleRun(startRunPayload); + : await startPuzzleRun(startRunPayload, runtimeAuthOptions); setSelectedPuzzleDetail(item); setPuzzleRun(run); setPuzzleRuntimeAuthMode(authMode); diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index a81ea87e..c0826a84 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -310,6 +310,7 @@ const RECOMMEND_RUNTIME_AUTH_OPTIONS = { ...ISOLATED_RUNTIME_AUTH_OPTIONS, runtimeGuestToken: 'runtime-guest-token', }; +const LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS = ISOLATED_RUNTIME_AUTH_OPTIONS; function getPlatformTabPanel(tab: string) { const panel = document.getElementById(`platform-tab-panel-${tab}`); @@ -6179,7 +6180,7 @@ test('home recommendation starts embedded puzzle without global auth reset on lo profileId: 'puzzle-profile-public-1', levelId: null, }, - RECOMMEND_RUNTIME_AUTH_OPTIONS, + LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS, ); }); }); @@ -6219,10 +6220,16 @@ test('home recommendation keeps logged-in puzzle start on default auth instead o profileId: 'puzzle-profile-public-2', levelId: null, }, + expect.objectContaining({ + authImpact: 'local', + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }), ); }); - expect(vi.mocked(startPuzzleRun).mock.calls[0]?.[1]).not.toEqual( - ISOLATED_RUNTIME_AUTH_OPTIONS, + expect(vi.mocked(startPuzzleRun).mock.calls[0]?.[1]).not.toHaveProperty( + 'runtimeGuestToken', ); }); @@ -6284,7 +6291,7 @@ test('home recommendation Match3D runtime keeps profile generated models when ca await waitFor(() => { expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith( 'match3d-profile-card-1', - RECOMMEND_RUNTIME_AUTH_OPTIONS, + LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS, ); }); await waitFor(() => { @@ -6603,11 +6610,13 @@ test('home recommendation surfaces start failure instead of staying in loading s expect( await screen.findByText('作品暂时无法进入,请稍后再试。'), ).toBeTruthy(); - expect( - within(getPlatformTabPanel('home')) - .queryByText('加载中...') - ?.closest('.platform-recommend-runtime-panel'), - ).toBeFalsy(); + await waitFor(() => { + expect( + within(getPlatformTabPanel('home')) + .queryByText('加载中...') + ?.closest('.platform-recommend-runtime-panel'), + ).toBeFalsy(); + }); }); test('published big fish works stay hidden from platform home and game category channel', async () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index fad7afd4..83c09957 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -2783,19 +2783,28 @@ test('logged out desktop recommend rail enters runtime without login modal', asy const user = userEvent.setup(); const openLoginModal = vi.fn(); - renderLoggedOutHomeView( + const { container } = renderLoggedOutHomeView( openLoginModal, { latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', }, 'category', + true, ); - await user.click(screen.getByRole('button', { name: '推荐' })); + const desktopRail = container.querySelector('.platform-desktop-rail'); + if (!desktopRail) { + throw new Error('缺少桌面侧边栏'); + } + + await user.click( + within(desktopRail as HTMLElement).getByRole('button', { name: '推荐' }), + ); expect(openLoginModal).not.toHaveBeenCalled(); - expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); + expect(screen.queryByTestId('recommend-runtime')).toBeNull(); + expect(container.querySelector('.platform-desktop-shell')).toBeTruthy(); }); test('logged in recommend page uses gated recommend detail callback', async () => {