diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 7447fd68..a2f568cb 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -50,6 +50,15 @@ - 验证方式:目标机 `nginx -T 2>/dev/null | grep client_max_body_size` 应看到 `client_max_body_size 64m;`;大于 1 MiB 的参考图请求不再在 Nginx 层直接 413,access log 应出现有效 `upstream_status`。 - 关联文档:`deploy/nginx/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 2026-05-22 抓大鹅素材生成改为关卡整图派生三图 +## 2026-05-24 跳一跳推荐页允许未登录直达运行态并记录匿名游玩埋点 + +- 背景:推荐页的跳一跳作品在未登录时曾被前端登录门禁拦住,导致公开推荐流无法直接游玩;同时游玩埋点如果只接受登录态 userId,会让匿名启动和匿名重开被静默丢失。 +- 决策:跳一跳推荐页的运行态启动、跳跃和重开路由统一使用可选鉴权;未登录时仍允许进入运行态,并把 `work_play_start` 以匿名语义记录下来,而不是伪造用户身份或直接跳过埋点。 +- 影响范围:`api-server` 跳一跳 runtime 路由、`work_play_tracking`、推荐页进入运行态逻辑、匿名推荐试玩测试、平台入口 / 玩法链路文档。 +- 验证:登录态和未登录态都能从推荐页进入运行态;`work_play_start` 事件在匿名时仍产生,metadata 带匿名标记。 +- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/api-server/src/auth.rs`、`server-rs/crates/api-server/src/work_play_tracking.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。 + ## 2026-05-22 抓大鹅素材生成改为关卡整图派生三图 - 背景:旧抓大鹅素材链路按物品 5x5 sheet、纯背景和独立容器图分开生产,难以保证背景、UI、容器和物品风格一致,也让结果页继续暴露背景 / 容器重生成入口。 @@ -59,6 +68,7 @@ - 验证方式:执行 `cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/services/match3dSpritesheetParser.test.ts src/services/match3dGeneratedModelCache.test.ts`、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-18 Rust 手写模块入口统一不用 mod.rs - 背景:Rust 目录模块同时存在 `mod.rs` 与同名 `.rs` 两种入口形式,前次拆分已让 `spacetime-client/src/mapper.rs` 采用同名入口;继续新增 `mod.rs` 会让文件定位和评审口径不一致。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 25c1b2df..18a2b4e0 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -129,6 +129,15 @@ - 验证:普通 route 请求在 SpacetimeDB 不可用时仍能返回,恢复后 sealed 文件会继续被清理。 - 关联:`server-rs/crates/api-server/src/tracking_outbox.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## release tracking outbox 权限错误先查 env 缺失 +## 跳一跳推荐页匿名直玩要同步放行 runtime 路由和埋点 + +- 现象:推荐页能看到跳一跳公开卡片,但未登录点击后会被登录门禁拦住,或者进入运行态后没有 `work_play_start` 记录。 +- 原因:前端只改了展示层登录门禁,后端 runtime 路由仍要求 bearer auth,或 tracking helper 仍把匿名请求当成无效输入直接丢弃。 +- 处理:`/api/runtime/jump-hop/runs`、`/jump`、`/restart` 改为可选鉴权;未登录时直接允许启动、跳跃和重开,同时让 `work_play_tracking` 接受 `Option` 用户身份并在 metadata 中标记匿名语义,不要伪造 userId。 +- 验证:未登录推荐页可以直接进入跳一跳运行态,且 `work_play_start` 事件仍会落库或出现在 outbox 中,metadata 含匿名标记。 +- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/api-server/src/auth.rs`、`server-rs/crates/api-server/src/work_play_tracking.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。 + ## release tracking outbox 权限错误先查 env 缺失 - 现象:release 机器 `journalctl -u genarrative-api.service` 每秒刷 `tracking outbox 定时封存 active 文件失败 error=Permission denied (os error 13)` 和 `tracking outbox 批量写入 SpacetimeDB 失败`。 @@ -137,6 +146,7 @@ - 验证:`tr '\0' '\n' < /proc/$(systemctl show genarrative-api.service -p MainPID --value)/environ | grep GENARRATIVE_TRACKING_OUTBOX_DIR` 应指向 `/var/lib/genarrative/tracking-outbox`;重启后当前 PID 不再出现 `Permission denied (os error 13)`。 - 关联:`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 外部 API 失败没法追溯先查 external_api_call_failure - 现象:VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片,还是下载图片失败。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 21bf0711..59e378e5 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -138,6 +138,13 @@ Codex 项目级 hook 已放在 `.codex/config.toml` 与 `.codex/hooks/`: - `npm run check:server-rs-ddd` - `npm run dev:api-server` 后请求 `/healthz` +其中推荐页匿名游玩与 `work_play_start` 相关改动,至少要补跑: + +```bash +cargo check -p api-server --manifest-path server-rs/Cargo.toml +npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged out recommend page can enter runtime without login gate|logged out desktop recommend page renders runtime directly without login gate" +``` + 涉及 SpacetimeDB schema 时必须补: ```bash diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index ad3b3c28..41bfde5e 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -120,6 +120,8 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 > 平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带角色图、地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。 +推荐页允许未登录直接游玩跳一跳运行态;`/api/runtime/jump-hop/runs`、`/jump` 和 `/restart` 采用可选鉴权,未登录时仍记录 `work_play_start`,但埋点需标记匿名语义。 + ## 敲木鱼 对外名称:`敲木鱼`。工程域:`wooden-fish`。PRD 见 `docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。 diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index c6a9e789..35cf5127 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -59,17 +59,44 @@ pub async fn require_bearer_auth( mut request: Request, next: Next, ) -> Result { + let Some(authenticated) = authenticate_request(&state, &request)? else { + return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); + }; + request.extensions_mut().insert(authenticated.clone()); + + let mut response = next.run(request).await; + response.extensions_mut().insert(authenticated); + + Ok(response) +} + +pub async fn attach_optional_bearer_auth( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + if let Some(authenticated) = authenticate_request(&state, &request)? { + request.extensions_mut().insert(authenticated.clone()); + let mut response = next.run(request).await; + response.extensions_mut().insert(authenticated); + return Ok(response); + } + + Ok(next.run(request).await) +} + +fn authenticate_request( + state: &AppState, + request: &Request, +) -> Result, AppError> { if allows_internal_forwarded_auth(request.uri().path()) && let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers()) { - request - .extensions_mut() - .insert(AuthenticatedAccessToken::new(claims.clone())); - let mut response = next.run(request).await; - response - .extensions_mut() - .insert(AuthenticatedAccessToken::new(claims)); - return Ok(response); + return Ok(Some(AuthenticatedAccessToken::new(claims))); + } + + if !request.headers().contains_key(AUTHORIZATION) { + return Ok(None); } let bearer_token = extract_bearer_token(request.headers())?; @@ -145,16 +172,7 @@ pub async fn require_bearer_auth( .with_message("当前登录态已失效,请重新登录")); } - request - .extensions_mut() - .insert(AuthenticatedAccessToken::new(claims.clone())); - - let mut response = next.run(request).await; - response - .extensions_mut() - .insert(AuthenticatedAccessToken::new(claims)); - - Ok(response) + Ok(Some(AuthenticatedAccessToken::new(claims))) } pub async fn inspect_auth_claims( diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index ec6d0a43..8ad45a0a 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -17,8 +17,12 @@ use spacetime_client::SpacetimeClientError; use std::time::{SystemTime, UNIX_EPOCH}; use crate::{ - api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, - request_context::RequestContext, state::AppState, + api_response::json_success_body, + auth::AuthenticatedAccessToken, + http_error::AppError, + request_context::RequestContext, + state::AppState, + work_play_tracking::{record_work_play_start_after_success, WorkPlayTrackingDraft}, }; const JUMP_HOP_PROVIDER: &str = "jump-hop"; @@ -26,6 +30,8 @@ const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime"; const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; +const JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID: &str = "anonymous-runtime"; +const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs"; pub async fn create_jump_hop_session( State(state): State, @@ -170,14 +176,18 @@ pub async fn get_jump_hop_runtime_work( pub async fn start_jump_hop_run( State(state): State, Extension(request_context): Extension, - Extension(authenticated): Extension, + maybe_authenticated: Option>, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; ensure_non_empty(&request_context, &payload.profile_id, "profileId")?; + let authenticated = maybe_authenticated.as_ref().map(|Extension(authenticated)| authenticated); + let owner_user_id = authenticated + .map(|authenticated| authenticated.claims().user_id().to_string()) + .unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string()); let run = state .spacetime_client() - .start_jump_hop_run(payload, authenticated.claims().user_id().to_string()) + .start_jump_hop_run(payload, owner_user_id.clone()) .await .map_err(|error| { jump_hop_error_response( @@ -187,6 +197,24 @@ pub async fn start_jump_hop_run( ) })?; + record_work_play_start_after_success( + &state, + &request_context, + build_jump_hop_work_play_tracking_draft( + authenticated, + run.profile_id.clone(), + JUMP_HOP_RUNTIME_RUNS_ROUTE, + ) + .owner_user_id(run.owner_user_id.clone()) + .run_id(run.run_id.clone()) + .profile_id(run.profile_id.clone()) + .extra(json!({ + "runStatus": run.status, + "isAnonymous": maybe_authenticated.is_none(), + })), + ) + .await; + Ok(json_success_body( Some(&request_context), JumpHopRunResponse { run }, @@ -197,18 +225,18 @@ pub async fn jump_hop_run_jump( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + maybe_authenticated: Option>, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; + let owner_user_id = maybe_authenticated + .as_ref() + .map(|Extension(authenticated)| authenticated.claims().user_id().to_string()) + .unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string()); let run = state .spacetime_client() - .jump_hop_run_jump( - run_id, - authenticated.claims().user_id().to_string(), - payload, - ) + .jump_hop_run_jump(run_id, owner_user_id, payload) .await .map_err(|error| { jump_hop_error_response( @@ -228,18 +256,18 @@ pub async fn restart_jump_hop_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, - Extension(authenticated): Extension, + maybe_authenticated: Option>, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; + let owner_user_id = maybe_authenticated + .as_ref() + .map(|Extension(authenticated)| authenticated.claims().user_id().to_string()) + .unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string()); let run = state .spacetime_client() - .restart_jump_hop_run( - run_id, - authenticated.claims().user_id().to_string(), - payload, - ) + .restart_jump_hop_run(run_id, owner_user_id, payload) .await .map_err(|error| { jump_hop_error_response( @@ -298,6 +326,19 @@ pub async fn get_jump_hop_gallery_detail( )) } +fn build_jump_hop_work_play_tracking_draft( + authenticated: Option<&AuthenticatedAccessToken>, + work_id: impl Into, + source_route: &'static str, +) -> WorkPlayTrackingDraft { + match authenticated { + Some(authenticated) => { + WorkPlayTrackingDraft::new("jump-hop", work_id, authenticated, source_route) + } + None => WorkPlayTrackingDraft::anonymous("jump-hop", work_id, source_route), + } +} + fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse { JumpHopDraftResponse { template_id: JUMP_HOP_TEMPLATE_ID.to_string(), diff --git a/server-rs/crates/api-server/src/modules/jump_hop.rs b/server-rs/crates/api-server/src/modules/jump_hop.rs index 7648fe91..42374060 100644 --- a/server-rs/crates/api-server/src/modules/jump_hop.rs +++ b/server-rs/crates/api-server/src/modules/jump_hop.rs @@ -4,7 +4,7 @@ use axum::{ }; use crate::{ - auth::require_bearer_auth, + auth::{attach_optional_bearer_auth, require_bearer_auth}, jump_hop::{ create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail, get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery, @@ -51,21 +51,21 @@ pub fn router(state: AppState) -> Router { "/api/runtime/jump-hop/runs", post(start_jump_hop_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + attach_optional_bearer_auth, )), ) .route( "/api/runtime/jump-hop/runs/{run_id}/jump", post(jump_hop_run_jump).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + attach_optional_bearer_auth, )), ) .route( "/api/runtime/jump-hop/runs/{run_id}/restart", post(restart_jump_hop_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + attach_optional_bearer_auth, )), ) .route("/api/runtime/jump-hop/gallery", get(list_jump_hop_gallery)) diff --git a/server-rs/crates/api-server/src/work_play_tracking.rs b/server-rs/crates/api-server/src/work_play_tracking.rs index 33d722db..f443b1e1 100644 --- a/server-rs/crates/api-server/src/work_play_tracking.rs +++ b/server-rs/crates/api-server/src/work_play_tracking.rs @@ -13,7 +13,7 @@ pub(crate) const WORK_PLAY_START_EVENT_KEY: &str = "work_play_start"; pub(crate) struct WorkPlayTrackingDraft { pub play_type: &'static str, pub work_id: String, - pub user_id: String, + pub user_id: Option, pub owner_user_id: Option, pub profile_id: Option, pub run_id: Option, @@ -28,7 +28,28 @@ impl WorkPlayTrackingDraft { authenticated: &AuthenticatedAccessToken, source_route: &'static str, ) -> Self { - let user_id = authenticated.claims().user_id().to_string(); + Self::with_user_id( + play_type, + work_id, + Some(authenticated.claims().user_id().to_string()), + source_route, + ) + } + + pub(crate) fn anonymous( + play_type: &'static str, + work_id: impl Into, + source_route: &'static str, + ) -> Self { + Self::with_user_id(play_type, work_id, None, source_route) + } + + fn with_user_id( + play_type: &'static str, + work_id: impl Into, + user_id: Option, + source_route: &'static str, + ) -> Self { Self { play_type, work_id: work_id.into(), @@ -91,7 +112,11 @@ async fn record_work_play_start_input_after_success( "workId": draft.work_id, "sourceRoute": draft.source_route, }); - metadata["userId"] = json!(draft.user_id); + if let Some(user_id) = draft.user_id.as_deref() { + metadata["userId"] = json!(user_id); + } else { + metadata["userKind"] = json!("anonymous"); + } if let Some(owner_user_id) = draft.owner_user_id.as_deref() { metadata["ownerUserId"] = json!(owner_user_id); } @@ -108,7 +133,7 @@ async fn record_work_play_start_input_after_success( let mut tracking = TrackingEventDraft::new(WORK_PLAY_START_EVENT_KEY, draft.play_type); tracking.scope_kind = RuntimeTrackingScopeKind::Work; tracking.scope_id = draft.work_id; - tracking.user_id = Some(draft.user_id); + tracking.user_id = draft.user_id; tracking.owner_user_id = draft.owner_user_id; tracking.profile_id = draft.profile_id; tracking.metadata = metadata; diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index cb7e7088..fcc0976f 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -7492,7 +7492,10 @@ export function PlatformEntryFlowShellImpl({ try { const [detail, runResponse] = await Promise.all([ jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null), - jumpHopClient.startRun(normalizedProfileId), + jumpHopClient.startRun( + normalizedProfileId, + options.embedded ? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS : {}, + ), ]); if (detail?.item) { setJumpHopWork(detail.item); @@ -11681,8 +11684,6 @@ export function PlatformEntryFlowShellImpl({ if ( selectionStage !== 'platform' || platformBootstrap.platformTab !== 'home' || - !platformBootstrap.isAuthenticated || - !platformBootstrap.canReadProtectedData || platformBootstrap.isLoadingPlatform ) { return; @@ -11734,9 +11735,7 @@ export function PlatformEntryFlowShellImpl({ jumpHopRun, isStartingRecommendEntry, match3dRun, - platformBootstrap.canReadProtectedData, platformBootstrap.isLoadingPlatform, - platformBootstrap.isAuthenticated, platformBootstrap.platformTab, puzzleRun, recommendRuntimeEntries, diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index ab7a6f1d..6e107061 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -2495,17 +2495,34 @@ test('logged out recommend cover opens login modal again', async () => { expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); -test('logged out desktop recommend page renders cover only', () => { +test('logged out desktop recommend page renders runtime directly', () => { mockDesktopLayout(); renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', }); - expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy(); + expect(document.querySelector('.platform-recommend-cover-only')).toBeNull(); expect(screen.queryByText('今日游戏')).toBeNull(); expect(screen.queryByText('作品分类')).toBeNull(); - expect(screen.queryByTestId('recommend-runtime')).toBeNull(); + expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); +}); + +test('logged out recommend page can enter runtime without login gate', () => { + mockDesktopLayout(); + const openLoginModal = vi.fn(); + const onOpenGalleryDetail = vi.fn(); + renderLoggedOutHomeView(openLoginModal, { + latestEntries: [puzzlePublicEntry], + activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', + onOpenGalleryDetail, + recommendRuntimeContent:
运行内容
, + }); + + expect(screen.queryByRole('button', { name: /登录后游玩 奇幻拼图/u })).toBeNull(); + expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); + expect(openLoginModal).not.toHaveBeenCalled(); + expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); test('logged in recommend page uses gated recommend detail callback', async () => { @@ -2581,7 +2598,7 @@ test('logged in recommend page uses gated recommend detail callback', async () = expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); -test('logged out mobile recommend page renders cover instead of runtime', () => { +test('logged out mobile recommend page renders runtime instead of cover', () => { const onOpenGalleryDetail = vi.fn(); renderLoggedOutHomeView( vi.fn(), @@ -2593,13 +2610,13 @@ test('logged out mobile recommend page renders cover instead of runtime', () => 'home', ); - expect(screen.queryByTestId('recommend-runtime')).toBeNull(); - expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy(); + expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); + expect(document.querySelector('.platform-recommend-cover-only')).toBeNull(); expect( document.querySelector('.platform-public-work-card__cover'), ).toBeNull(); expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0); - fireEvent.click(screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u })); + expect(screen.queryByRole('button', { name: /登录后游玩 奇幻拼图/u })).toBeNull(); expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); @@ -2611,7 +2628,8 @@ test('mobile recommend loading state is themed instead of hardcoded black', () = recommendRuntimeContent: null, }); - expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy(); + expect(document.querySelector('.platform-recommend-cover-only')).toBeNull(); + expect(screen.getByText('加载中...')).toBeTruthy(); }); test('logged in recommend runtime preloads adjacent work previews and drag switches like video feed', () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 881fb5d7..8d804fc2 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -5273,16 +5273,6 @@ export function RpgEntryHomeView({ 正在读取公开作品... - ) : !isAuthenticated && activeRecommendEntry ? ( -
- -
) : recommendRuntimeError ? (