修正跳一跳排行榜展示名

新增排行榜 displayName 契约并在 api-server 出口补齐展示名

调整跳一跳结果页和运行态排行榜只显示 displayName

补充禁止展示 user_id 的前后端回归测试

更新跳一跳 PRD、后端契约文档和 Hermes 决策记录
This commit is contained in:
2026-06-07 16:25:58 +08:00
parent 8dca8a6443
commit 78791af424
12 changed files with 116 additions and 12 deletions

View File

@@ -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"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。

View File

@@ -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. 结果页

View File

@@ -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`

View File

@@ -294,6 +294,7 @@ export interface JumpHopJumpResponse {
export interface JumpHopLeaderboardEntry {
rank: number;
playerId: string;
displayName: string;
successfulJumpCount: number;
durationMs: number;
updatedAt: string;

View File

@@ -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>,
) -> 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<String> {
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("森林冒险", "森林主题清爽游戏化立体感平台");

View File

@@ -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,

View File

@@ -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),

View File

@@ -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', () => {

View File

@@ -231,7 +231,9 @@ function JumpHopResultLeaderboard({
<span className="text-[var(--platform-text-soft)]">
{entry.rank}
</span>
<span className="truncate">{entry.playerId}</span>
<span className="truncate">
{entry.displayName?.trim() || '玩家'}
</span>
<span>{entry.successfulJumpCount} </span>
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
</div>

View File

@@ -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();
});

View File

@@ -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"
>
<span className="text-slate-400">{entry.rank}</span>
<span className="truncate">{entry.playerId}</span>
<span className="truncate">
{entry.displayName?.trim() || '玩家'}
</span>
<span>{entry.successfulJumpCount} </span>
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
</div>

View File

@@ -30,6 +30,7 @@ const leaderboardResponse: JumpHopLeaderboardResponse = {
{
rank: 1,
playerId: 'player-1',
displayName: '玩家一号',
successfulJumpCount: 10,
durationMs: 3210,
updatedAt: '2026-05-27T00:00:00Z',