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;
}
}