diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index afd5b8d8..a112fb91 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -98,6 +98,8 @@ npm run check:server-rs-ddd 该拆分只改变 `api-server` 文件组织,不改变 `/api/runtime/puzzle/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉。 +`/api/runtime/puzzle/runs*` 当前接受 `RuntimePrincipal`,可同时识别登录用户 Bearer 和 runtime guest token。推荐页嵌入运行态的正式开局、交换、拖拽、下一关、暂停、道具与排行榜请求,应由前端在登录态下继续携带账号 access token;匿名游客仅在确认为未登录时走 runtime guest token。不要再把拼图 runtime 当成只认普通 Bearer 的纯账号接口。 + 抓大鹅 Match3D `api-server` 内部拆分: - `server-rs/crates/api-server/src/modules/match3d.rs` 继续负责路由装配和 body limit;对外 handler 名称保持不变。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index f0f8ff9d..c8a231d0 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -152,7 +152,7 @@ Codex 项目级 hook 已放在 `.codex/config.toml` 与 `.codex/hooks/`: ```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" +npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged out recommend tab enters runtime without login modal|logged out desktop recommend page renders runtime directly|logged out desktop recommend rail enters runtime without login modal" ``` 涉及 SpacetimeDB schema 时必须补: diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 6a5029af..229c0095 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -92,6 +92,7 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `次级入口 > - 结果页单关测试只能把完整草稿持久化,并通过 `levelId` 指定运行态起始关卡;不得把单关快照作为整份草稿调用 `updatePuzzleWork`,否则 source session 和作品 profile 的 `levels` 会被覆盖成单关,退出重进后其它关卡会丢失。 - 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。 - 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回和设置按钮的点击容器只提供透明点击区,不再叠加默认白色圆形底;底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。 +- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,启动拼图和后续排行榜 / 下一关等正式请求继续走账号 Bearer;只有确认为匿名访客时才申请并透传 runtime guest token。`/api/runtime/puzzle/runs*` 后端统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest;推荐卡片的后台读写请求仍使用 local auth impact,避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。 - 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。 - 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。 - 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。 diff --git a/server-rs/crates/api-server/src/modules/puzzle.rs b/server-rs/crates/api-server/src/modules/puzzle.rs index fc2e18cb..8cecce64 100644 --- a/server-rs/crates/api-server/src/modules/puzzle.rs +++ b/server-rs/crates/api-server/src/modules/puzzle.rs @@ -6,7 +6,7 @@ use axum::{ }; use crate::{ - auth::require_bearer_auth, + auth::{require_bearer_auth, require_runtime_principal_auth}, puzzle::{ advance_puzzle_next_level, claim_puzzle_work_point_incentive, create_puzzle_agent_session, delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action, @@ -130,56 +130,56 @@ pub fn router(state: AppState) -> Router { "/api/runtime/puzzle/runs", post(start_puzzle_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}", get(get_puzzle_run).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/swap", post(swap_puzzle_pieces).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/drag", post(drag_puzzle_piece_or_group).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/next-level", post(advance_puzzle_next_level).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/pause", post(update_puzzle_run_pause).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/props", post(use_puzzle_runtime_prop).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .route( "/api/runtime/puzzle/runs/{run_id}/leaderboard", post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state( state.clone(), - require_bearer_auth, + require_runtime_principal_auth, )), ) .with_state(PuzzleApiState::from_ref(&state)) diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 88132af9..7d7b5331 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -76,7 +76,7 @@ use crate::{ execute_billable_asset_operation, execute_billable_asset_operation_with_cost, should_skip_asset_operation_billing_for_connectivity, }, - auth::AuthenticatedAccessToken, + auth::{AuthenticatedAccessToken, RuntimePrincipal}, generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha, http_error::AppError, llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL}, diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 63be5836..46834284 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -1666,7 +1666,7 @@ pub async fn remix_puzzle_gallery_work( pub async fn start_puzzle_run( State(state): State, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { @@ -1690,7 +1690,7 @@ pub async fn start_puzzle_run( .spacetime_client() .start_puzzle_run(PuzzleRunStartRecordInput { run_id: build_prefixed_uuid_id("puzzle-run-"), - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), profile_id: payload.profile_id.clone(), level_id: payload.level_id.clone(), started_at_micros: current_utc_micros(), @@ -1707,16 +1707,18 @@ pub async fn start_puzzle_run( record_puzzle_work_play_start_after_success( &state, &request_context, - WorkPlayTrackingDraft::new( + WorkPlayTrackingDraft::runtime_principal( "puzzle", payload.profile_id.clone(), - &authenticated, + &principal, "/api/runtime/puzzle/...", ) .profile_id(payload.profile_id.clone()) + .owner_user_id(principal.subject().to_string()) .extra(json!({ "levelId": payload.level_id, "runId": run.run_id, + "principalKind": principal.kind().as_str(), })), ) .await; @@ -1733,13 +1735,13 @@ pub async fn get_puzzle_run( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() - .get_puzzle_run(run_id, authenticated.claims().user_id().to_string()) + .get_puzzle_run(run_id, principal.subject().to_string()) .await .map_err(|error| { puzzle_error_response( @@ -1761,7 +1763,7 @@ pub async fn swap_puzzle_pieces( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { @@ -1792,7 +1794,7 @@ pub async fn swap_puzzle_pieces( .spacetime_client() .swap_puzzle_pieces(PuzzleRunSwapRecordInput { run_id, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), first_piece_id: payload.first_piece_id, second_piece_id: payload.second_piece_id, swapped_at_micros: current_utc_micros(), @@ -1818,7 +1820,7 @@ pub async fn drag_puzzle_piece_or_group( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { @@ -1843,7 +1845,7 @@ pub async fn drag_puzzle_piece_or_group( .spacetime_client() .drag_puzzle_piece_or_group(PuzzleRunDragRecordInput { run_id, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), piece_id: payload.piece_id, target_row: payload.target_row, target_col: payload.target_col, @@ -1870,7 +1872,7 @@ pub async fn advance_puzzle_next_level( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; @@ -1897,7 +1899,7 @@ pub async fn advance_puzzle_next_level( .spacetime_client() .advance_puzzle_next_level(spacetime_client::PuzzleRunNextLevelRecordInput { run_id, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), target_profile_id: payload.target_profile_id, advanced_at_micros: current_utc_micros(), }) @@ -1922,7 +1924,7 @@ pub async fn update_puzzle_run_pause( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { @@ -1941,7 +1943,7 @@ pub async fn update_puzzle_run_pause( .spacetime_client() .update_puzzle_run_pause(PuzzleRunPauseRecordInput { run_id, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), paused: payload.paused, updated_at_micros: current_utc_micros(), }) @@ -1966,7 +1968,7 @@ pub async fn use_puzzle_runtime_prop( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { @@ -1987,7 +1989,7 @@ pub async fn use_puzzle_runtime_prop( "propKind", )?; - let owner_user_id = authenticated.claims().user_id().to_string(); + let owner_user_id = principal.subject().to_string(); let prop_kind = payload.prop_kind.trim().to_string(); let billing_asset_kind = match prop_kind.as_str() { "hint" => "puzzle_prop_hint", @@ -2064,7 +2066,7 @@ pub async fn submit_puzzle_leaderboard( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, - Extension(authenticated): Extension, + Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { @@ -2084,7 +2086,7 @@ pub async fn submit_puzzle_leaderboard( .spacetime_client() .submit_puzzle_leaderboard_entry(PuzzleLeaderboardSubmitRecordInput { run_id, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id: principal.subject().to_string(), profile_id: payload.profile_id, grid_size: payload.grid_size, elapsed_ms: payload.elapsed_ms.max(1_000), diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index e0d871ec..a4537c20 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -115,6 +115,7 @@ import { resolveWorkNotFoundRecoveryAction } from '../../routing/runtimeNotFound import { ApiClientError, BACKGROUND_AUTH_REQUEST_OPTIONS, + getStoredAccessToken, } from '../../services/apiClient'; import { ensureRuntimeGuestToken, @@ -559,6 +560,25 @@ async function buildRecommendRuntimeGuestOptions() { runtimeGuestToken: token, }; } +function shouldUseRecommendRuntimeGuestAuth( + authUi: { user?: { id?: string } | null } | null | undefined, +) { + return !authUi?.user?.id?.trim() && !getStoredAccessToken(); +} +async function buildRecommendRuntimeAuthOptions( + authUi: { user?: { id?: string } | null } | null | undefined, + embedded?: boolean, +) { + if (!embedded) { + return {}; + } + + if (shouldUseRecommendRuntimeGuestAuth(authUi)) { + return buildRecommendRuntimeGuestOptions(); + } + + return RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS; +} const PUZZLE_DRAFT_GENERATION_POINT_COST = 2; const MATCH3D_DRAFT_GENERATION_POINT_COST = 10; const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3; @@ -7386,9 +7406,10 @@ export function PlatformEntryFlowShellImpl({ profileId: targetProfileId, mode: 'play' as const, }; - const runtimeGuestOptions = options.embedded - ? await buildRecommendRuntimeGuestOptions() - : {}; + const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( + authUi, + options.embedded, + ); const { run } = options.embedded ? await startVisualNovelRun( targetProfileId, @@ -7419,6 +7440,7 @@ export function PlatformEntryFlowShellImpl({ } }, [ + authUi, resolvePuzzleErrorMessage, setIsVisualNovelBusy, setSelectionStage, @@ -7442,7 +7464,7 @@ export function PlatformEntryFlowShellImpl({ try { const runtimeGuestOptions = activeRecommendRuntimeKind === 'visual-novel' - ? await buildRecommendRuntimeGuestOptions() + ? await buildRecommendRuntimeAuthOptions(authUi, true) : {}; const nextRun = await streamVisualNovelRuntimeAction( visualNovelRun.runId, @@ -7460,6 +7482,7 @@ export function PlatformEntryFlowShellImpl({ }, [ activeRecommendRuntimeKind, + authUi, isVisualNovelBusy, resolvePuzzleErrorMessage, setIsVisualNovelBusy, @@ -7868,9 +7891,10 @@ export function PlatformEntryFlowShellImpl({ setJumpHopError(null); setJumpHopRuntimeReturnStage(options.returnStage ?? 'work-detail'); try { - const runtimeGuestOptions = options.embedded - ? await buildRecommendRuntimeGuestOptions() - : {}; + const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( + authUi, + options.embedded, + ); const [detail, runResponse] = await Promise.all([ jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null), jumpHopClient.startRun(normalizedProfileId, runtimeGuestOptions), @@ -7898,7 +7922,7 @@ export function PlatformEntryFlowShellImpl({ setIsJumpHopBusy(false); } }, - [setSelectionStage], + [authUi, setSelectionStage], ); const restartJumpHopRuntimeRun = useCallback(async () => { @@ -8205,9 +8229,10 @@ export function PlatformEntryFlowShellImpl({ setWoodenFishError(null); setWoodenFishRuntimeReturnStage(options.returnStage ?? 'work-detail'); try { - const runtimeGuestOptions = options.embedded - ? await buildRecommendRuntimeGuestOptions() - : {}; + const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( + authUi, + options.embedded, + ); const [detail, runResponse] = await Promise.all([ woodenFishClient.getWorkDetail(normalizedProfileId).catch(() => null), options.embedded @@ -8237,7 +8262,7 @@ export function PlatformEntryFlowShellImpl({ setIsWoodenFishBusy(false); } }, - [setSelectionStage], + [authUi, setSelectionStage], ); const checkpointWoodenFishRuntimeRun = useCallback( @@ -8640,12 +8665,14 @@ export function PlatformEntryFlowShellImpl({ profileId: item.profileId, levelId: levelId ?? null, }; - const runtimeGuestOptions = options.embedded + const canUseRuntimeGuestAuth = + options.embedded || options.authMode === 'isolated'; + const useRuntimeGuestAuth = + canUseRuntimeGuestAuth && shouldUseRecommendRuntimeGuestAuth(authUi); + const runtimeGuestOptions = useRuntimeGuestAuth ? await buildRecommendRuntimeGuestOptions() : {}; - const authMode = options.embedded - ? 'isolated' - : (options.authMode ?? 'default'); + const authMode = useRuntimeGuestAuth ? 'isolated' : 'default'; const { run } = authMode === 'isolated' ? await startPuzzleRun(startRunPayload, runtimeGuestOptions) @@ -8692,6 +8719,7 @@ export function PlatformEntryFlowShellImpl({ }, [ isPuzzleBusy, + authUi, resolvePuzzleErrorMessage, setIsPuzzleBusy, setPuzzleError, @@ -8744,9 +8772,10 @@ export function PlatformEntryFlowShellImpl({ runtimeProfile.generatedBackgroundAsset, { expireSeconds: 300 }, ); - const runtimeGuestOptions = options.embedded - ? await buildRecommendRuntimeGuestOptions() - : {}; + const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( + authUi, + options.embedded, + ); const runtimeOptions = { ...runtimeGuestOptions, ...(typeof options.itemTypeCountOverride === 'number' @@ -8793,6 +8822,7 @@ export function PlatformEntryFlowShellImpl({ }, [ isMatch3DBusy, + authUi, match3dFlow, match3dRuntimeAdapter, resolveMatch3DErrorMessage, @@ -8816,9 +8846,10 @@ export function PlatformEntryFlowShellImpl({ setSquareHoleError(null); try { - const runtimeGuestOptions = options.embedded - ? await buildRecommendRuntimeGuestOptions() - : {}; + const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( + authUi, + options.embedded, + ); const { run } = options.embedded ? await startSquareHoleRun(profile.profileId, runtimeGuestOptions) : await startSquareHoleRun(profile.profileId); @@ -8852,6 +8883,7 @@ export function PlatformEntryFlowShellImpl({ }, [ isSquareHoleBusy, + authUi, resolveSquareHoleErrorMessage, setSelectionStage, setSquareHoleError, @@ -8974,7 +9006,7 @@ export function PlatformEntryFlowShellImpl({ try { const runtimeGuestOptions = activeRecommendRuntimeKind === 'big-fish' - ? await buildRecommendRuntimeGuestOptions() + ? await buildRecommendRuntimeAuthOptions(authUi, true) : {}; const { run } = await submitBigFishRuntimeInput( bigFishRun.runId, @@ -8992,6 +9024,7 @@ export function PlatformEntryFlowShellImpl({ }, [ activeRecommendRuntimeKind, + authUi, bigFishRun, resolveBigFishErrorMessage, setBigFishError, @@ -9008,10 +9041,9 @@ export function PlatformEntryFlowShellImpl({ setBigFishRuntimeStartedAt(null); const reportPromise = activeRecommendRuntimeKind === 'big-fish' - ? recordBigFishPlay( - sessionId, - { elapsedMs }, - RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS, + ? buildRecommendRuntimeAuthOptions(authUi, true).then( + (runtimeAuthOptions) => + recordBigFishPlay(sessionId, { elapsedMs }, runtimeAuthOptions), ) : recordBigFishPlay(sessionId, { elapsedMs }); void reportPromise.catch((error) => { @@ -9021,6 +9053,7 @@ export function PlatformEntryFlowShellImpl({ }); }, [ activeRecommendRuntimeKind, + authUi, bigFishRun?.sessionId, bigFishRuntimeStartedAt, resolveBigFishErrorMessage, @@ -11315,9 +11348,10 @@ export function PlatformEntryFlowShellImpl({ setBigFishRuntimeReturnStage(returnStage); setBigFishRun(null); try { - const runtimeGuestOptions = options.embedded - ? await buildRecommendRuntimeGuestOptions() - : {}; + const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( + authUi, + options.embedded, + ); const { run } = options.embedded ? await startBigFishRuntimeRun(sessionId, runtimeGuestOptions) : await startBigFishRuntimeRun(sessionId); @@ -11345,7 +11379,7 @@ export function PlatformEntryFlowShellImpl({ return false; } }, - [bigFishFlow, resolveBigFishErrorMessage, setBigFishError, setSelectionStage], + [authUi, bigFishFlow, resolveBigFishErrorMessage, setBigFishError, setSelectionStage], ); const startBarkBattleRunFromWork = useCallback( @@ -11365,9 +11399,10 @@ export function PlatformEntryFlowShellImpl({ setBarkBattlePublishedConfig(mapBarkBattleWorkToPublishedConfig(item)); setBarkBattleRuntimeReturnStage(returnStage); try { - const runtimeGuestOptions = options.embedded - ? await buildRecommendRuntimeGuestOptions() - : {}; + const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( + authUi, + options.embedded, + ); const runResponse = options.embedded ? await startBarkBattleRun(item.workId, {}, runtimeGuestOptions) : await startBarkBattleRun(item.workId); @@ -11390,7 +11425,7 @@ export function PlatformEntryFlowShellImpl({ return false; } }, - [resolveBarkBattleErrorMessage, setSelectionStage], + [authUi, resolveBarkBattleErrorMessage, setSelectionStage], ); const startSelectedPublicWork = useCallback(() => { diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 036b09fe..6e0dc361 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -6121,11 +6121,52 @@ test('home recommendation starts embedded puzzle without global auth reset on lo profileId: 'puzzle-profile-public-1', levelId: null, }, - ISOLATED_RUNTIME_AUTH_OPTIONS, ); }); }); +test('home recommendation keeps logged-in puzzle start on default auth instead of guest token', async () => { + const publishedPuzzleWork = { + workId: 'puzzle-work-public-2', + profileId: 'puzzle-profile-public-2', + ownerUserId: 'user-2', + sourceSessionId: 'puzzle-session-public-2', + 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, + }); + + render(); + + await waitFor(() => { + expect(startPuzzleRun).toHaveBeenCalledWith( + { + profileId: 'puzzle-profile-public-2', + levelId: null, + }, + ); + }); + expect(vi.mocked(startPuzzleRun).mock.calls[0]?.[1]).not.toEqual( + ISOLATED_RUNTIME_AUTH_OPTIONS, + ); +}); + test('home recommendation Match3D runtime keeps profile generated models when card summary is stale', async () => { const match3dCard: Match3DWorkSummary = { workId: 'match3d-work-card-1', @@ -7135,7 +7176,6 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa profileId: 'puzzle-profile-public-1', levelId: null, }, - ISOLATED_RUNTIME_AUTH_OPTIONS, ); vi.mocked(listProfileSaveArchives).mockClear(); vi.mocked(listProfileSaveArchives).mockRejectedValueOnce( @@ -7159,7 +7199,6 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa elapsedMs: 18_000, nickname: '测试玩家', }, - ISOLATED_RUNTIME_AUTH_OPTIONS, ); }); @@ -7180,7 +7219,6 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa expect(advancePuzzleNextLevel).toHaveBeenCalledWith( clearedFirstLevel.runId, {}, - ISOLATED_RUNTIME_AUTH_OPTIONS, ); }); expect( @@ -7343,7 +7381,6 @@ test('formal puzzle similar work keeps current run level progression', async () expect(advancePuzzleNextLevel).toHaveBeenCalledWith( clearedThirdLevel.runId, { targetProfileId: 'puzzle-profile-similar-2' }, - ISOLATED_RUNTIME_AUTH_OPTIONS, ); }); expect(startPuzzleRun).not.toHaveBeenCalled(); @@ -7527,7 +7564,6 @@ test('recommend puzzle remix return restarts recommendation instead of stale loa profileId: 'puzzle-profile-public-1', levelId: null, }, - ISOLATED_RUNTIME_AUTH_OPTIONS, ); }); expect(screen.queryByText('正在进入拼图关卡')).toBeNull(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index a7c4e5a4..74f7ebc4 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -2697,7 +2697,7 @@ test('logged out mobile shell defaults to discover tab', () => { ).toBeNull(); }); -test('logged out recommend tab opens login modal and shows cover only', async () => { +test('logged out recommend tab enters runtime without login modal', async () => { const user = userEvent.setup(); const { container, openLoginModal } = renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry], @@ -2712,20 +2712,18 @@ test('logged out recommend tab opens login modal and shows cover only', async () within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }), ); - expect(openLoginModal).toHaveBeenCalledTimes(1); - expect( - container.querySelector('.platform-recommend-cover-only'), - ).toBeTruthy(); + expect(openLoginModal).not.toHaveBeenCalled(); + expect(container.querySelector('.platform-recommend-cover-only')).toBeNull(); expect(container.querySelector('.platform-mobile-topbar')).toBeNull(); expect( container.querySelector('.platform-mobile-entry-shell--recommend'), ).toBeTruthy(); - expect(screen.queryByTestId('recommend-runtime')).toBeNull(); - expect(screen.queryByLabelText('奇幻拼图 作品信息')).toBeNull(); + expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); + expect(screen.getByLabelText('奇幻拼图 作品信息')).toBeTruthy(); expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0); }); -test('logged out recommend cover opens login modal again', async () => { +test('logged out recommend page keeps runtime visible without login gate', async () => { const user = userEvent.setup(); const onOpenGalleryDetail = vi.fn(); const { openLoginModal } = renderStatefulLoggedOutHomeView({ @@ -2741,12 +2739,9 @@ test('logged out recommend cover opens login modal again', async () => { await user.click( within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }), ); - await user.click( - screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u }), - ); - expect(openLoginModal).toHaveBeenCalledTimes(2); - expect(openLoginModal).toHaveBeenLastCalledWith(); + expect(openLoginModal).not.toHaveBeenCalled(); + expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); @@ -2780,6 +2775,26 @@ test('logged out recommend page can enter runtime without login gate', () => { expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); +test('logged out desktop recommend rail enters runtime without login modal', async () => { + mockDesktopLayout(); + const user = userEvent.setup(); + const openLoginModal = vi.fn(); + + renderLoggedOutHomeView( + openLoginModal, + { + latestEntries: [puzzlePublicEntry], + activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', + }, + 'category', + ); + + await user.click(screen.getByRole('button', { name: '推荐' })); + + expect(openLoginModal).not.toHaveBeenCalled(); + expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); +}); + test('logged in recommend page uses gated recommend detail callback', async () => { const user = userEvent.setup(); const onOpenGalleryDetail = vi.fn(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 30017806..b7f6199d 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -5372,7 +5372,7 @@ export function RpgEntryHomeView({ {recommendRuntimeError} - ) : isStartingRecommendEntry || !recommendRuntimeContent ? ( + ) : isStartingRecommendEntry ? (
加载中...
@@ -6761,12 +6761,6 @@ export function RpgEntryHomeView({ return; } - if (!isAuthenticated && tab === 'home') { - onTabChange(tab); - authUi?.openLoginModal(); - return; - } - onTabChange(tab); }} /> @@ -6924,12 +6918,6 @@ export function RpgEntryHomeView({ emphasized={tab === 'create'} showDot={tab === 'saves' && hasUnreadDraftUpdate} onClick={() => { - if (!isAuthenticated && tab === 'home') { - onTabChange(tab); - authUi?.openLoginModal(); - return; - } - onTabChange(tab); }} /> diff --git a/src/components/rpg-entry/useRpgEntryBootstrap.ts b/src/components/rpg-entry/useRpgEntryBootstrap.ts index 9aec011f..ded70c37 100644 --- a/src/components/rpg-entry/useRpgEntryBootstrap.ts +++ b/src/components/rpg-entry/useRpgEntryBootstrap.ts @@ -351,7 +351,7 @@ export function useRpgEntryBootstrap( !hasInitialAgentSession && !hasExplicitPlatformTabSelectionRef.current ) { - // 中文注释:新用户先进入发现页;推荐页只在用户主动点击后作为登录门禁入口。 + // 中文注释:新用户先进入发现页;推荐页可直接进入,真正受保护的动作再单独做登录门禁。 setPlatformTabState(isAuthenticated ? 'home' : 'category'); } } finally {