From 78791af4245be15264037066d01718eaa29f039f Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 7 Jun 2026 16:25:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=B7=B3=E4=B8=80=E8=B7=B3?= =?UTF-8?q?=E6=8E=92=E8=A1=8C=E6=A6=9C=E5=B1=95=E7=A4=BA=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增排行榜 displayName 契约并在 api-server 出口补齐展示名 调整跳一跳结果页和运行态排行榜只显示 displayName 补充禁止展示 user_id 的前后端回归测试 更新跳一跳 PRD、后端契约文档和 Hermes 决策记录 --- .hermes/shared-memory/decision-log.md | 8 ++ ...创作】跳一跳俯视角玩法模板PRD-2026-05-19.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + packages/shared/src/contracts/jumpHop.ts | 1 + server-rs/crates/api-server/src/jump_hop.rs | 85 ++++++++++++++++++- .../crates/shared-contracts/src/jump_hop.rs | 1 + .../spacetime-client/src/mapper/jump_hop.rs | 1 + .../JumpHopResultView.test.tsx | 12 ++- .../jump-hop-result/JumpHopResultView.tsx | 4 +- .../JumpHopRuntimeShell.test.tsx | 6 +- .../jump-hop-runtime/JumpHopRuntimeShell.tsx | 4 +- .../jump-hop/useJumpHopLeaderboard.test.tsx | 1 + 12 files changed, 116 insertions(+), 12 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 2b746a75..9ce085fc 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -24,6 +24,14 @@ - 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-07 跳一跳排行榜展示名禁止泄露内部身份键 + +- 背景:跳一跳排行榜曾在结果页和运行态失败弹窗里直接展示 `playerId` / `user_id`,用户可见内容暴露了内部身份键。 +- 决策:`jump_hop_leaderboard_entry.player_id` 只作为 SpacetimeDB read model 的去重和 `viewerBest` 匹配字段,HTTP 契约新增并强制使用 `displayName` 作为排行榜展示字段。api-server 出口按账号 `displayName` 补齐展示名;匿名 runtime guest 固定展示“游客玩家”;账号失效或不可解析时展示“失效玩家”;前端排行榜 UI 禁止兜底展示 `playerId` / `user_id`。 +- 影响范围:`packages/shared/src/contracts/jumpHop.ts`、`server-rs/crates/shared-contracts/src/jump_hop.rs`、`server-rs/crates/api-server/src/jump_hop.rs`、跳一跳结果页和运行态排行榜组件、跳一跳 PRD 与后端契约文档。 +- 验证方式:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx -t "排行榜"`、`npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx -t "排行榜"`、`cargo test -p api-server jump_hop_leaderboard_display_name_never_falls_back_to_player_id --manifest-path server-rs/Cargo.toml`。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-06-06 小程序微信绑定展示使用原生昵称组件 - 背景:账号信息面板需要显示“绑定的是哪个微信号”。微信小程序登录 `jscode2session` 不返回昵称或个人微信号,但小程序提供 `input type="nickname"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。 diff --git a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md index 6bbc45af..07751c9e 100644 --- a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md +++ b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md @@ -135,11 +135,13 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc 展示字段: 1. rank; -2. playerId; +2. displayName; 3. successfulJumpCount; 4. durationMs; 5. updatedAt。 +排行榜 UI 禁止展示 `user_id` / `playerId` 这类内部身份键。后端可以继续用 `playerId` 做作品维度最佳成绩去重和 `viewerBest` 匹配,但 HTTP 响应必须补齐 `displayName`;已登录用户读取账号 `displayName`,匿名游客展示为“游客玩家”,账号失效或无法解析时展示为“失效玩家”。 + 草稿试玩可以展示本地结果,但正式排行榜只消费后端 run 记录。匿名 runtime guest 也按 guest subject 作为 playerId 参与当次作品维度排行。 ## 8. 结果页 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index f54145fd..2b996168 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -414,6 +414,7 @@ npm run check:server-rs-ddd - Rust 结构体:`JumpHopLeaderboardEntryRow` - 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs` - 说明:跳一跳作品维度排行榜 read model,每个 `profile_id + player_id` 只保留 1 条最佳记录;排序口径为成功跳跃次数降序、游戏时长升序、更新时间升序,草稿试玩不作为公开排行榜语义。 +- 展示契约:`player_id` 只作为后端去重和 `viewerBest` 匹配身份键,不得直接进入 HTTP/UI 展示字段;`/api/runtime/jump-hop/works/{profile_id}/leaderboard` 必须补齐 `displayName`,已登录玩家读取账号显示名,匿名游客展示“游客玩家”,失效账号展示“失效玩家”。 ### `jump_hop_runtime_run` diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts index a5b6d9e9..8b2621e1 100644 --- a/packages/shared/src/contracts/jumpHop.ts +++ b/packages/shared/src/contracts/jumpHop.ts @@ -294,6 +294,7 @@ export interface JumpHopJumpResponse { export interface JumpHopLeaderboardEntry { rank: number; playerId: string; + displayName: string; successfulJumpCount: number; durationMs: number; updatedAt: string; diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index c1372fc3..55914d7e 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -13,7 +13,8 @@ use serde_json::{Value, json}; use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse, JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, - JumpHopLeaderboardResponse, JumpHopRestartRunRequest, JumpHopRunResponse, + JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopRestartRunRequest, + JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, @@ -327,8 +328,14 @@ pub async fn get_jump_hop_leaderboard( Some(&request_context), JumpHopLeaderboardResponse { profile_id: leaderboard.profile_id, - items: leaderboard.items, - viewer_best: leaderboard.viewer_best, + items: leaderboard + .items + .into_iter() + .map(|entry| resolve_jump_hop_leaderboard_entry_display_name(&state, entry)) + .collect(), + viewer_best: leaderboard + .viewer_best + .map(|entry| resolve_jump_hop_leaderboard_entry_display_name(&state, entry)), }, )) } @@ -1301,6 +1308,51 @@ fn build_jump_hop_work_play_tracking_draft( WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route) } +fn resolve_jump_hop_leaderboard_entry_display_name( + state: &AppState, + mut entry: JumpHopLeaderboardEntry, +) -> JumpHopLeaderboardEntry { + entry.display_name = resolve_jump_hop_leaderboard_display_name(state, &entry.player_id); + entry +} + +fn resolve_jump_hop_leaderboard_display_name(state: &AppState, player_id: &str) -> String { + resolve_jump_hop_leaderboard_display_name_with_lookup(player_id, |user_id| { + state + .auth_user_service() + .get_user_by_id(user_id) + .ok() + .flatten() + .and_then(|user| normalize_non_empty_text(user.display_name.as_str())) + }) +} + +fn resolve_jump_hop_leaderboard_display_name_with_lookup( + player_id: &str, + lookup_display_name: impl FnOnce(&str) -> Option, +) -> String { + let player_id = player_id.trim(); + if player_id.is_empty() { + return "玩家".to_string(); + } + if player_id.starts_with("guest-runtime-") { + return "游客玩家".to_string(); + } + + lookup_display_name(player_id) + .and_then(|display_name| normalize_non_empty_text(display_name.as_str())) + .unwrap_or_else(|| "失效玩家".to_string()) +} + +fn normalize_non_empty_text(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + fn is_jump_hop_draft_runtime_mode(runtime_mode: &str) -> bool { runtime_mode.trim().eq_ignore_ascii_case("draft") } @@ -1495,6 +1547,33 @@ mod tests { assert!(!is_jump_hop_draft_runtime_mode("")); } + #[test] + fn jump_hop_leaderboard_display_name_never_falls_back_to_player_id() { + assert_eq!( + resolve_jump_hop_leaderboard_display_name_with_lookup(" user-secret-1 ", |user_id| { + assert_eq!(user_id, "user-secret-1"); + Some(" 陶泥儿玩家 ".to_string()) + }), + "陶泥儿玩家" + ); + assert_eq!( + resolve_jump_hop_leaderboard_display_name_with_lookup("guest-runtime-1", |_| { + panic!("guest player should not query account display name") + }), + "游客玩家" + ); + assert_eq!( + resolve_jump_hop_leaderboard_display_name_with_lookup("user-missing", |_| None), + "失效玩家" + ); + assert_eq!( + resolve_jump_hop_leaderboard_display_name_with_lookup("", |_| { + panic!("empty player id should not query account display name") + }), + "玩家" + ); + } + #[test] fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() { let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台"); diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs index cbad6f68..826130f4 100644 --- a/server-rs/crates/shared-contracts/src/jump_hop.rs +++ b/server-rs/crates/shared-contracts/src/jump_hop.rs @@ -443,6 +443,7 @@ pub struct JumpHopJumpResponse { pub struct JumpHopLeaderboardEntry { pub rank: u32, pub player_id: String, + pub display_name: String, pub successful_jump_count: u32, pub duration_ms: u64, pub updated_at: String, diff --git a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs index eec6ba97..5a5a8a5e 100644 --- a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs @@ -342,6 +342,7 @@ fn map_jump_hop_leaderboard_entry_snapshot( JumpHopLeaderboardEntry { rank: snapshot.rank, player_id: snapshot.player_id, + display_name: String::new(), successful_jump_count: snapshot.successful_jump_count, duration_ms: snapshot.duration_ms, updated_at: format_timestamp_micros(snapshot.updated_at_micros), diff --git a/src/components/jump-hop-result/JumpHopResultView.test.tsx b/src/components/jump-hop-result/JumpHopResultView.test.tsx index f7ae9578..3727d5e5 100644 --- a/src/components/jump-hop-result/JumpHopResultView.test.tsx +++ b/src/components/jump-hop-result/JumpHopResultView.test.tsx @@ -28,14 +28,16 @@ test('跳一跳结果页展示排行榜列表', () => { items: [ { rank: 1, - playerId: 'player-1', + playerId: 'user-secret-1', + displayName: '陶泥儿玩家', successfulJumpCount: 12, durationMs: 40123, updatedAt: '2026-05-27T00:00:00Z', }, { rank: 2, - playerId: 'player-2', + playerId: 'user-secret-2', + displayName: '森林玩家', successfulJumpCount: 10, durationMs: 38210, updatedAt: '2026-05-26T00:00:00Z', @@ -60,10 +62,12 @@ test('跳一跳结果页展示排行榜列表', () => { ); expect(screen.getByText('排行榜')).toBeTruthy(); - expect(screen.getByText('player-1')).toBeTruthy(); + expect(screen.getByText('陶泥儿玩家')).toBeTruthy(); + expect(screen.queryByText('user-secret-1')).toBeNull(); expect(screen.getByText('12 跳')).toBeTruthy(); expect(screen.getByText('00:40')).toBeTruthy(); - expect(screen.getByText('player-2')).toBeTruthy(); + expect(screen.getByText('森林玩家')).toBeTruthy(); + expect(screen.queryByText('user-secret-2')).toBeNull(); }); test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => { diff --git a/src/components/jump-hop-result/JumpHopResultView.tsx b/src/components/jump-hop-result/JumpHopResultView.tsx index 959c200c..28bd2fe4 100644 --- a/src/components/jump-hop-result/JumpHopResultView.tsx +++ b/src/components/jump-hop-result/JumpHopResultView.tsx @@ -231,7 +231,9 @@ function JumpHopResultLeaderboard({ {entry.rank} - {entry.playerId} + + {entry.displayName?.trim() || '玩家'} + {entry.successfulJumpCount} 跳 {formatJumpHopDurationLabel(entry.durationMs)} diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx index c6332727..e5497b6a 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx @@ -327,7 +327,8 @@ test('跳一跳运行态失败后在弹窗中展示排行榜', () => { items: [ { rank: 1, - playerId: 'player-1', + playerId: 'user-secret-1', + displayName: '陶泥儿玩家', successfulJumpCount: 8, durationMs: 8123, updatedAt: '2026-05-27T00:00:00Z', @@ -357,7 +358,8 @@ test('跳一跳运行态失败后在弹窗中展示排行榜', () => { expect(screen.getByRole('dialog', { name: '失败' })).toBeTruthy(); const leaderboard = screen.getByTestId('jump-hop-runtime-leaderboard'); expect(leaderboard).toBeTruthy(); - expect(within(leaderboard).getByText('player-1')).toBeTruthy(); + expect(within(leaderboard).getByText('陶泥儿玩家')).toBeTruthy(); + expect(within(leaderboard).queryByText('user-secret-1')).toBeNull(); expect(within(leaderboard).getByText('8 跳')).toBeTruthy(); expect(within(leaderboard).getByText('00:08')).toBeTruthy(); }); diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx index 09f47e17..70ee83a0 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx @@ -548,7 +548,9 @@ function JumpHopLeaderboardPanel({ className="grid grid-cols-[1.5rem_minmax(0,1fr)_auto_auto] items-center gap-2 text-xs font-bold text-slate-700" > {entry.rank} - {entry.playerId} + + {entry.displayName?.trim() || '玩家'} + {entry.successfulJumpCount} 跳 {formatJumpHopDurationLabel(entry.durationMs)} diff --git a/src/services/jump-hop/useJumpHopLeaderboard.test.tsx b/src/services/jump-hop/useJumpHopLeaderboard.test.tsx index b21079bd..c7e41f0f 100644 --- a/src/services/jump-hop/useJumpHopLeaderboard.test.tsx +++ b/src/services/jump-hop/useJumpHopLeaderboard.test.tsx @@ -30,6 +30,7 @@ const leaderboardResponse: JumpHopLeaderboardResponse = { { rank: 1, playerId: 'player-1', + displayName: '玩家一号', successfulJumpCount: 10, durationMs: 3210, updatedAt: '2026-05-27T00:00:00Z',