修正跳一跳排行榜展示名
新增排行榜 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`。
|
- 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`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 小程序微信绑定展示使用原生昵称组件
|
## 2026-06-06 小程序微信绑定展示使用原生昵称组件
|
||||||
|
|
||||||
- 背景:账号信息面板需要显示“绑定的是哪个微信号”。微信小程序登录 `jscode2session` 不返回昵称或个人微信号,但小程序提供 `input type="nickname"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。
|
- 背景:账号信息面板需要显示“绑定的是哪个微信号”。微信小程序登录 `jscode2session` 不返回昵称或个人微信号,但小程序提供 `input type="nickname"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。
|
||||||
|
|||||||
@@ -135,11 +135,13 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc
|
|||||||
展示字段:
|
展示字段:
|
||||||
|
|
||||||
1. rank;
|
1. rank;
|
||||||
2. playerId;
|
2. displayName;
|
||||||
3. successfulJumpCount;
|
3. successfulJumpCount;
|
||||||
4. durationMs;
|
4. durationMs;
|
||||||
5. updatedAt。
|
5. updatedAt。
|
||||||
|
|
||||||
|
排行榜 UI 禁止展示 `user_id` / `playerId` 这类内部身份键。后端可以继续用 `playerId` 做作品维度最佳成绩去重和 `viewerBest` 匹配,但 HTTP 响应必须补齐 `displayName`;已登录用户读取账号 `displayName`,匿名游客展示为“游客玩家”,账号失效或无法解析时展示为“失效玩家”。
|
||||||
|
|
||||||
草稿试玩可以展示本地结果,但正式排行榜只消费后端 run 记录。匿名 runtime guest 也按 guest subject 作为 playerId 参与当次作品维度排行。
|
草稿试玩可以展示本地结果,但正式排行榜只消费后端 run 记录。匿名 runtime guest 也按 guest subject 作为 playerId 参与当次作品维度排行。
|
||||||
|
|
||||||
## 8. 结果页
|
## 8. 结果页
|
||||||
|
|||||||
@@ -414,6 +414,7 @@ npm run check:server-rs-ddd
|
|||||||
- Rust 结构体:`JumpHopLeaderboardEntryRow`
|
- Rust 结构体:`JumpHopLeaderboardEntryRow`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
|
||||||
- 说明:跳一跳作品维度排行榜 read model,每个 `profile_id + player_id` 只保留 1 条最佳记录;排序口径为成功跳跃次数降序、游戏时长升序、更新时间升序,草稿试玩不作为公开排行榜语义。
|
- 说明:跳一跳作品维度排行榜 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`
|
### `jump_hop_runtime_run`
|
||||||
|
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ export interface JumpHopJumpResponse {
|
|||||||
export interface JumpHopLeaderboardEntry {
|
export interface JumpHopLeaderboardEntry {
|
||||||
rank: number;
|
rank: number;
|
||||||
playerId: string;
|
playerId: string;
|
||||||
|
displayName: string;
|
||||||
successfulJumpCount: number;
|
successfulJumpCount: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ use serde_json::{Value, json};
|
|||||||
use shared_contracts::jump_hop::{
|
use shared_contracts::jump_hop::{
|
||||||
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
|
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
|
||||||
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
|
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
|
||||||
JumpHopLeaderboardResponse, JumpHopRestartRunRequest, JumpHopRunResponse,
|
JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopRestartRunRequest,
|
||||||
|
JumpHopRunResponse,
|
||||||
JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest,
|
JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest,
|
||||||
JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
||||||
JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
|
JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
|
||||||
@@ -327,8 +328,14 @@ pub async fn get_jump_hop_leaderboard(
|
|||||||
Some(&request_context),
|
Some(&request_context),
|
||||||
JumpHopLeaderboardResponse {
|
JumpHopLeaderboardResponse {
|
||||||
profile_id: leaderboard.profile_id,
|
profile_id: leaderboard.profile_id,
|
||||||
items: leaderboard.items,
|
items: leaderboard
|
||||||
viewer_best: leaderboard.viewer_best,
|
.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)
|
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 {
|
fn is_jump_hop_draft_runtime_mode(runtime_mode: &str) -> bool {
|
||||||
runtime_mode.trim().eq_ignore_ascii_case("draft")
|
runtime_mode.trim().eq_ignore_ascii_case("draft")
|
||||||
}
|
}
|
||||||
@@ -1495,6 +1547,33 @@ mod tests {
|
|||||||
assert!(!is_jump_hop_draft_runtime_mode(""));
|
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]
|
#[test]
|
||||||
fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() {
|
fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() {
|
||||||
let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台");
|
let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台");
|
||||||
|
|||||||
@@ -443,6 +443,7 @@ pub struct JumpHopJumpResponse {
|
|||||||
pub struct JumpHopLeaderboardEntry {
|
pub struct JumpHopLeaderboardEntry {
|
||||||
pub rank: u32,
|
pub rank: u32,
|
||||||
pub player_id: String,
|
pub player_id: String,
|
||||||
|
pub display_name: String,
|
||||||
pub successful_jump_count: u32,
|
pub successful_jump_count: u32,
|
||||||
pub duration_ms: u64,
|
pub duration_ms: u64,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ fn map_jump_hop_leaderboard_entry_snapshot(
|
|||||||
JumpHopLeaderboardEntry {
|
JumpHopLeaderboardEntry {
|
||||||
rank: snapshot.rank,
|
rank: snapshot.rank,
|
||||||
player_id: snapshot.player_id,
|
player_id: snapshot.player_id,
|
||||||
|
display_name: String::new(),
|
||||||
successful_jump_count: snapshot.successful_jump_count,
|
successful_jump_count: snapshot.successful_jump_count,
|
||||||
duration_ms: snapshot.duration_ms,
|
duration_ms: snapshot.duration_ms,
|
||||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||||
|
|||||||
@@ -28,14 +28,16 @@ test('跳一跳结果页展示排行榜列表', () => {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
rank: 1,
|
rank: 1,
|
||||||
playerId: 'player-1',
|
playerId: 'user-secret-1',
|
||||||
|
displayName: '陶泥儿玩家',
|
||||||
successfulJumpCount: 12,
|
successfulJumpCount: 12,
|
||||||
durationMs: 40123,
|
durationMs: 40123,
|
||||||
updatedAt: '2026-05-27T00:00:00Z',
|
updatedAt: '2026-05-27T00:00:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rank: 2,
|
rank: 2,
|
||||||
playerId: 'player-2',
|
playerId: 'user-secret-2',
|
||||||
|
displayName: '森林玩家',
|
||||||
successfulJumpCount: 10,
|
successfulJumpCount: 10,
|
||||||
durationMs: 38210,
|
durationMs: 38210,
|
||||||
updatedAt: '2026-05-26T00:00:00Z',
|
updatedAt: '2026-05-26T00:00:00Z',
|
||||||
@@ -60,10 +62,12 @@ test('跳一跳结果页展示排行榜列表', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('排行榜')).toBeTruthy();
|
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('12 跳')).toBeTruthy();
|
||||||
expect(screen.getByText('00:40')).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', () => {
|
test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => {
|
||||||
|
|||||||
@@ -231,7 +231,9 @@ function JumpHopResultLeaderboard({
|
|||||||
<span className="text-[var(--platform-text-soft)]">
|
<span className="text-[var(--platform-text-soft)]">
|
||||||
{entry.rank}
|
{entry.rank}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{entry.playerId}</span>
|
<span className="truncate">
|
||||||
|
{entry.displayName?.trim() || '玩家'}
|
||||||
|
</span>
|
||||||
<span>{entry.successfulJumpCount} 跳</span>
|
<span>{entry.successfulJumpCount} 跳</span>
|
||||||
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
|
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -327,7 +327,8 @@ test('跳一跳运行态失败后在弹窗中展示排行榜', () => {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
rank: 1,
|
rank: 1,
|
||||||
playerId: 'player-1',
|
playerId: 'user-secret-1',
|
||||||
|
displayName: '陶泥儿玩家',
|
||||||
successfulJumpCount: 8,
|
successfulJumpCount: 8,
|
||||||
durationMs: 8123,
|
durationMs: 8123,
|
||||||
updatedAt: '2026-05-27T00:00:00Z',
|
updatedAt: '2026-05-27T00:00:00Z',
|
||||||
@@ -357,7 +358,8 @@ test('跳一跳运行态失败后在弹窗中展示排行榜', () => {
|
|||||||
expect(screen.getByRole('dialog', { name: '失败' })).toBeTruthy();
|
expect(screen.getByRole('dialog', { name: '失败' })).toBeTruthy();
|
||||||
const leaderboard = screen.getByTestId('jump-hop-runtime-leaderboard');
|
const leaderboard = screen.getByTestId('jump-hop-runtime-leaderboard');
|
||||||
expect(leaderboard).toBeTruthy();
|
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('8 跳')).toBeTruthy();
|
||||||
expect(within(leaderboard).getByText('00:08')).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"
|
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="text-slate-400">{entry.rank}</span>
|
||||||
<span className="truncate">{entry.playerId}</span>
|
<span className="truncate">
|
||||||
|
{entry.displayName?.trim() || '玩家'}
|
||||||
|
</span>
|
||||||
<span>{entry.successfulJumpCount} 跳</span>
|
<span>{entry.successfulJumpCount} 跳</span>
|
||||||
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
|
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const leaderboardResponse: JumpHopLeaderboardResponse = {
|
|||||||
{
|
{
|
||||||
rank: 1,
|
rank: 1,
|
||||||
playerId: 'player-1',
|
playerId: 'player-1',
|
||||||
|
displayName: '玩家一号',
|
||||||
successfulJumpCount: 10,
|
successfulJumpCount: 10,
|
||||||
durationMs: 3210,
|
durationMs: 3210,
|
||||||
updatedAt: '2026-05-27T00:00:00Z',
|
updatedAt: '2026-05-27T00:00:00Z',
|
||||||
|
|||||||
Reference in New Issue
Block a user