修正跳一跳排行榜展示名
新增排行榜 displayName 契约并在 api-server 出口补齐展示名 调整跳一跳结果页和运行态排行榜只显示 displayName 补充禁止展示 user_id 的前后端回归测试 更新跳一跳 PRD、后端契约文档和 Hermes 决策记录
This commit is contained in:
@@ -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"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。
|
||||
|
||||
@@ -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. 结果页
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -294,6 +294,7 @@ export interface JumpHopJumpResponse {
|
||||
export interface JumpHopLeaderboardEntry {
|
||||
rank: number;
|
||||
playerId: string;
|
||||
displayName: string;
|
||||
successfulJumpCount: number;
|
||||
durationMs: number;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -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("森林冒险", "森林主题清爽游戏化立体感平台");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -30,6 +30,7 @@ const leaderboardResponse: JumpHopLeaderboardResponse = {
|
||||
{
|
||||
rank: 1,
|
||||
playerId: 'player-1',
|
||||
displayName: '玩家一号',
|
||||
successfulJumpCount: 10,
|
||||
durationMs: 3210,
|
||||
updatedAt: '2026-05-27T00:00:00Z',
|
||||
|
||||
Reference in New Issue
Block a user