合并 master 并修复架构分支回归
合入 master 最新的认证、玩法契约与推荐页改动。 修复拼图草稿生成、推荐页下一关和公开详情访客试玩回归。 修复抓大鹅草稿试玩鉴权与首屏推荐详情测试入口。 补齐相关测试夹具、文档与团队记忆更新。
This commit is contained in:
@@ -2640,6 +2640,7 @@ mod tests {
|
||||
login_payload["bindingStatus"],
|
||||
Value::String("pending_bind_phone".to_string())
|
||||
);
|
||||
assert_eq!(login_payload["created"], Value::Bool(true));
|
||||
assert_eq!(
|
||||
login_payload["user"]["loginMethod"],
|
||||
Value::String("wechat".to_string())
|
||||
@@ -2746,6 +2747,7 @@ mod tests {
|
||||
login_payload["bindingStatus"],
|
||||
Value::String("pending_bind_phone".to_string())
|
||||
);
|
||||
assert_eq!(login_payload["created"], Value::Bool(true));
|
||||
|
||||
let bind_response = app
|
||||
.oneshot(
|
||||
@@ -4423,4 +4425,4 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -222,6 +223,34 @@ pub async fn list_jump_hop_works(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_jump_hop_work_detail(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||||
let work = state
|
||||
.spacetime_client()
|
||||
.get_jump_hop_work_profile(
|
||||
profile_id,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
jump_hop_error_response(
|
||||
&request_context,
|
||||
JUMP_HOP_CREATION_PROVIDER,
|
||||
map_jump_hop_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
JumpHopWorkDetailResponse { item: work },
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_jump_hop_work(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
@@ -231,7 +260,10 @@ pub async fn delete_jump_hop_work(
|
||||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||||
let works = state
|
||||
.spacetime_client()
|
||||
.delete_jump_hop_work(profile_id, authenticated.claims().user_id().to_string())
|
||||
.delete_jump_hop_work(
|
||||
profile_id,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
jump_hop_error_response(
|
||||
@@ -296,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)),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -1270,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")
|
||||
}
|
||||
@@ -1464,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("森林冒险", "森林主题清爽游戏化立体感平台");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
middleware,
|
||||
routing::{delete, get, post},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
|
||||
@@ -9,8 +9,9 @@ use crate::{
|
||||
jump_hop::{
|
||||
create_jump_hop_session, delete_jump_hop_work, execute_jump_hop_action,
|
||||
get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work,
|
||||
get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery, list_jump_hop_works,
|
||||
publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
|
||||
get_jump_hop_session, get_jump_hop_work_detail, jump_hop_run_jump,
|
||||
list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run,
|
||||
start_jump_hop_run,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -47,10 +48,12 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
)
|
||||
.route(
|
||||
"/api/creation/jump-hop/works/{profile_id}",
|
||||
delete(delete_jump_hop_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
get(get_jump_hop_work_detail)
|
||||
.delete(delete_jump_hop_work)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/jump-hop/works/{profile_id}/publish",
|
||||
|
||||
@@ -349,6 +349,7 @@ pub async fn login_wechat_mini_program(
|
||||
token: signed_session.access_token,
|
||||
binding_status: result.user.binding_status.as_str().to_string(),
|
||||
user: map_auth_user_payload(result.user),
|
||||
created: result.created,
|
||||
},
|
||||
),
|
||||
))
|
||||
|
||||
@@ -242,8 +242,8 @@ pub fn resolve_creation_entry_event_banner_responses(
|
||||
banners
|
||||
}
|
||||
.into_iter()
|
||||
.map(build_creation_entry_event_banner_response)
|
||||
.collect()
|
||||
.map(build_creation_entry_event_banner_response)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 把领域公告快照转换为 HTTP 响应字段。
|
||||
|
||||
@@ -339,12 +339,20 @@ mod tests {
|
||||
assert_eq!(banners.len(), 1);
|
||||
assert_eq!(banners[0].render_mode, "html");
|
||||
assert_eq!(banners[0].title, "创作公告");
|
||||
assert!(banners[0].html_code.as_deref().unwrap_or("").contains("创作公告"));
|
||||
assert!(banners[0]
|
||||
.html_code
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("/creation-type-references/puzzle.webp"));
|
||||
assert!(
|
||||
banners[0]
|
||||
.html_code
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("创作公告")
|
||||
);
|
||||
assert!(
|
||||
banners[0]
|
||||
.html_code
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("/creation-type-references/puzzle.webp")
|
||||
);
|
||||
assert_ne!(banners[0].cover_image_src, legacy_banner.cover_image_src);
|
||||
}
|
||||
|
||||
@@ -485,6 +493,60 @@ mod tests {
|
||||
assert_eq!(jump_hop.category_sort_order, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_response_uses_unified_creation_contract_title() {
|
||||
let response = build_creation_entry_config_response(CreationEntryConfigSnapshot {
|
||||
config_id: CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(),
|
||||
start_card: CreationEntryStartCardSnapshot {
|
||||
title: DEFAULT_CREATION_ENTRY_START_TITLE.to_string(),
|
||||
description: DEFAULT_CREATION_ENTRY_START_DESCRIPTION.to_string(),
|
||||
idle_badge: DEFAULT_CREATION_ENTRY_START_IDLE_BADGE.to_string(),
|
||||
busy_badge: DEFAULT_CREATION_ENTRY_START_BUSY_BADGE.to_string(),
|
||||
},
|
||||
type_modal: CreationEntryTypeModalSnapshot {
|
||||
title: DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
|
||||
description: DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
|
||||
},
|
||||
event_banner: default_creation_entry_event_banner_snapshots()
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("default banner"),
|
||||
event_banners_json: Some(default_creation_entry_event_banners_json()),
|
||||
creation_types: vec![CreationEntryTypeSnapshot {
|
||||
id: "puzzle".to_string(),
|
||||
title: "定制拼图".to_string(),
|
||||
subtitle: "拼图关卡创作".to_string(),
|
||||
badge: "可创建".to_string(),
|
||||
image_src: "/creation-type-references/puzzle.webp".to_string(),
|
||||
visible: true,
|
||||
open: true,
|
||||
sort_order: 30,
|
||||
category_id: "recommended".to_string(),
|
||||
category_label: "热门推荐".to_string(),
|
||||
category_sort_order: 20,
|
||||
updated_at_micros: 1,
|
||||
unified_creation_spec_json: Some(
|
||||
r#"{"playId":"puzzle","title":"想做个什么玩法?","workspaceStage":"puzzle-agent-workspace","generationStage":"puzzle-generating","resultStage":"puzzle-result","fields":[{"id":"pictureDescription","kind":"text","label":"画面描述","required":true}]}"#
|
||||
.to_string(),
|
||||
),
|
||||
}],
|
||||
updated_at_micros: 1,
|
||||
});
|
||||
let puzzle = response
|
||||
.creation_types
|
||||
.iter()
|
||||
.find(|item| item.id == "puzzle")
|
||||
.expect("puzzle entry");
|
||||
|
||||
assert_eq!(
|
||||
puzzle
|
||||
.unified_creation_spec
|
||||
.as_ref()
|
||||
.map(|spec| spec.title.as_str()),
|
||||
Some("想做个什么玩法?")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_clamps_music_volume_into_valid_range() {
|
||||
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);
|
||||
|
||||
@@ -253,6 +253,7 @@ pub struct WechatMiniProgramLoginResponse {
|
||||
pub token: String,
|
||||
pub binding_status: String,
|
||||
pub user: AuthUserPayload,
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
pub fn build_available_login_methods(
|
||||
@@ -389,4 +390,29 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wechat_mini_program_login_response_marks_created_user() {
|
||||
let payload = serde_json::to_value(WechatMiniProgramLoginResponse {
|
||||
token: "token-001".to_string(),
|
||||
binding_status: AUTH_BINDING_STATUS_PENDING_BIND_PHONE.to_string(),
|
||||
user: AuthUserPayload {
|
||||
id: "user_001".to_string(),
|
||||
public_user_code: "SY-00000001".to_string(),
|
||||
display_name: "微信旅人".to_string(),
|
||||
avatar_url: None,
|
||||
phone_number: None,
|
||||
phone_number_masked: None,
|
||||
login_method: AUTH_LOGIN_METHOD_WECHAT.to_string(),
|
||||
binding_status: AUTH_BINDING_STATUS_PENDING_BIND_PHONE.to_string(),
|
||||
wechat_bound: true,
|
||||
wechat_display_name: None,
|
||||
wechat_account: Some("wx-openid-001".to_string()),
|
||||
},
|
||||
created: true,
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["created"], serde_json::Value::Bool(true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,16 +137,7 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
|
||||
"jump-hop-workspace",
|
||||
"jump-hop-generating",
|
||||
"jump-hop-result",
|
||||
vec![
|
||||
unified_creation_field("workTitle", "text", "作品标题", true),
|
||||
unified_creation_field("workDescription", "text", "作品简介", true),
|
||||
unified_creation_field("themeTags", "text", "主题标签", true),
|
||||
unified_creation_field("difficulty", "select", "难度", true),
|
||||
unified_creation_field("stylePreset", "select", "风格", true),
|
||||
unified_creation_field("characterPrompt", "text", "角色提示词", true),
|
||||
unified_creation_field("tilePrompt", "text", "地块提示词", true),
|
||||
unified_creation_field("endMoodPrompt", "text", "终点氛围", false),
|
||||
],
|
||||
vec![unified_creation_field("themeText", "text", "主题", true)],
|
||||
),
|
||||
"wooden-fish" => (
|
||||
"wooden-fish-workspace",
|
||||
@@ -210,7 +201,7 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
|
||||
|
||||
Some(UnifiedCreationSpecResponse {
|
||||
play_id: play_id.to_string(),
|
||||
title: "想做个什么玩法?".to_string(),
|
||||
title: default_unified_creation_title(play_id)?.to_string(),
|
||||
workspace_stage: workspace_stage.to_string(),
|
||||
generation_stage: generation_stage.to_string(),
|
||||
result_stage: result_stage.to_string(),
|
||||
@@ -218,6 +209,23 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default_unified_creation_title(play_id: &str) -> Option<&'static str> {
|
||||
match play_id {
|
||||
"rpg" => Some("文字冒险"),
|
||||
"big-fish" => Some("摸鱼"),
|
||||
"puzzle" => Some("拼图"),
|
||||
"match3d" => Some("抓大鹅"),
|
||||
"jump-hop" => Some("跳一跳"),
|
||||
"wooden-fish" => Some("敲木鱼"),
|
||||
"square-hole" => Some("方洞"),
|
||||
"bark-battle" => Some("汪汪声浪"),
|
||||
"visual-novel" => Some("视觉小说"),
|
||||
"baby-object-match" => Some("宝贝识物"),
|
||||
"creative-agent" => Some("智能体创作"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_unified_creation_spec_response(
|
||||
spec: &UnifiedCreationSpecResponse,
|
||||
) -> Result<(), String> {
|
||||
@@ -328,10 +336,12 @@ mod tests {
|
||||
#[test]
|
||||
fn phase1_unified_creation_specs_cover_existing_templates() {
|
||||
let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec");
|
||||
assert_eq!(puzzle.title, "拼图");
|
||||
assert_eq!(puzzle.fields[0].id, "pictureDescription");
|
||||
assert_eq!(puzzle.fields[1].kind, "image");
|
||||
|
||||
let match3d = build_phase1_unified_creation_spec("match3d").expect("match3d spec");
|
||||
assert_eq!(match3d.title, "抓大鹅");
|
||||
assert_eq!(
|
||||
match3d
|
||||
.fields
|
||||
@@ -342,18 +352,9 @@ mod tests {
|
||||
);
|
||||
|
||||
let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec");
|
||||
assert!(
|
||||
jump_hop
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "stylePreset")
|
||||
);
|
||||
assert!(
|
||||
jump_hop
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "endMoodPrompt")
|
||||
);
|
||||
assert_eq!(jump_hop.title, "跳一跳");
|
||||
assert_eq!(jump_hop.fields.len(), 1);
|
||||
assert_eq!(jump_hop.fields[0].id, "themeText");
|
||||
|
||||
let wooden_fish =
|
||||
build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec");
|
||||
@@ -379,6 +380,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_creation_spec_title_uses_contract_content() {
|
||||
let raw = r#"{
|
||||
"playId": "puzzle",
|
||||
"title": "想做个什么玩法?",
|
||||
"workspaceStage": "puzzle-agent-workspace",
|
||||
"generationStage": "puzzle-generating",
|
||||
"resultStage": "puzzle-result",
|
||||
"fields": [
|
||||
{
|
||||
"id": "pictureDescription",
|
||||
"kind": "text",
|
||||
"label": "画面描述",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let spec =
|
||||
resolve_unified_creation_spec_response("puzzle", Some(raw)).expect("puzzle spec");
|
||||
|
||||
assert_eq!(spec.title, "想做个什么玩法?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banner_defaults_to_structured_render_mode() {
|
||||
let banner = serde_json::from_str::<CreationEntryEventBannerResponse>(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1662,9 +1662,7 @@ fn get_custom_world_gallery_detail_record(
|
||||
.find(&input.profile_id)
|
||||
.filter(|row| {
|
||||
row.owner_user_id == input.owner_user_id
|
||||
&& row.publication_status == CustomWorldPublicationStatus::Published
|
||||
&& row.deleted_at.is_none()
|
||||
&& row.visible
|
||||
&& is_custom_world_profile_publicly_interactive(row)
|
||||
});
|
||||
|
||||
let gallery_entry = ctx
|
||||
@@ -1712,8 +1710,7 @@ fn get_custom_world_gallery_detail_record_by_code(
|
||||
.find(&row.profile_id)
|
||||
.filter(|profile_row| {
|
||||
profile_row.owner_user_id == row.owner_user_id
|
||||
&& profile_row.publication_status == CustomWorldPublicationStatus::Published
|
||||
&& profile_row.deleted_at.is_none()
|
||||
&& is_custom_world_profile_publicly_interactive(profile_row)
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1756,12 +1753,7 @@ fn remix_custom_world_profile_record(
|
||||
.profile_id()
|
||||
.find(&source_profile_id.to_string())
|
||||
.filter(|row| row.owner_user_id == source_owner_user_id)
|
||||
.filter(|row| {
|
||||
row.publication_status == CustomWorldPublicationStatus::Published
|
||||
&& row.deleted_at.is_none()
|
||||
&& row.visible
|
||||
&& row.published_at.is_some()
|
||||
})
|
||||
.filter(is_custom_world_profile_publicly_interactive)
|
||||
.ok_or_else(|| "custom_world 已发布源作品不存在,无法改编".to_string())?;
|
||||
let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros);
|
||||
|
||||
@@ -1859,12 +1851,7 @@ fn record_custom_world_profile_play_record(
|
||||
.profile_id()
|
||||
.find(&profile_id.to_string())
|
||||
.filter(|row| row.owner_user_id == owner_user_id)
|
||||
.filter(|row| {
|
||||
row.publication_status == CustomWorldPublicationStatus::Published
|
||||
&& row.deleted_at.is_none()
|
||||
&& row.visible
|
||||
&& row.published_at.is_some()
|
||||
})
|
||||
.filter(is_custom_world_profile_publicly_interactive)
|
||||
.ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?;
|
||||
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
|
||||
|
||||
@@ -1932,12 +1919,7 @@ fn record_custom_world_profile_like_record(
|
||||
.profile_id()
|
||||
.find(&profile_id.to_string())
|
||||
.filter(|row| row.owner_user_id == owner_user_id)
|
||||
.filter(|row| {
|
||||
row.publication_status == CustomWorldPublicationStatus::Published
|
||||
&& row.deleted_at.is_none()
|
||||
&& row.visible
|
||||
&& row.published_at.is_some()
|
||||
})
|
||||
.filter(is_custom_world_profile_publicly_interactive)
|
||||
.ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?;
|
||||
let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros);
|
||||
|
||||
@@ -1998,6 +1980,18 @@ fn record_custom_world_profile_like_record(
|
||||
))
|
||||
}
|
||||
|
||||
fn is_custom_world_profile_publicly_interactive(row: &CustomWorldProfile) -> bool {
|
||||
// 历史公开作品可能缺少 published_at;公开互动只按发布、未删除、可见判断。
|
||||
row.publication_status == CustomWorldPublicationStatus::Published
|
||||
&& row.deleted_at.is_none()
|
||||
&& row.visible
|
||||
}
|
||||
|
||||
fn resolve_custom_world_published_at(row: &CustomWorldProfile) -> Timestamp {
|
||||
// gallery 展示与同步兼容旧数据,用 updated_at 兜底公开时间。
|
||||
row.published_at.unwrap_or(row.updated_at)
|
||||
}
|
||||
|
||||
fn list_custom_world_work_snapshots(
|
||||
ctx: &ReducerContext,
|
||||
input: CustomWorldWorksListInput,
|
||||
@@ -4832,9 +4826,10 @@ fn sync_custom_world_gallery_entry_from_profile(
|
||||
ctx: &ReducerContext,
|
||||
profile: &CustomWorldProfile,
|
||||
) -> Result<CustomWorldGalleryEntrySnapshot, String> {
|
||||
let published_at = profile
|
||||
.published_at
|
||||
.ok_or_else(|| "published profile 缺少 published_at,无法同步 gallery".to_string())?;
|
||||
if profile.publication_status != CustomWorldPublicationStatus::Published {
|
||||
return Err("custom_world profile 未发布,无法同步 gallery".to_string());
|
||||
}
|
||||
let published_at = resolve_custom_world_published_at(profile);
|
||||
|
||||
ctx.db
|
||||
.custom_world_gallery_entry()
|
||||
@@ -4881,10 +4876,6 @@ fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(),
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for profile in published_profiles {
|
||||
if profile.published_at.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let existing_gallery_entry = ctx
|
||||
.db
|
||||
.custom_world_gallery_entry()
|
||||
@@ -5483,6 +5474,78 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_world_public_interactions_accept_legacy_missing_published_at() {
|
||||
fn build_profile(
|
||||
publication_status: CustomWorldPublicationStatus,
|
||||
published_at: Option<Timestamp>,
|
||||
deleted_at: Option<Timestamp>,
|
||||
visible: bool,
|
||||
) -> CustomWorldProfile {
|
||||
CustomWorldProfile {
|
||||
profile_id: "profile-legacy".to_string(),
|
||||
owner_user_id: "user-legacy".to_string(),
|
||||
public_work_code: Some("CW-3A9EC89B".to_string()),
|
||||
author_public_user_code: Some("SY-00000001".to_string()),
|
||||
source_agent_session_id: Some("session-legacy".to_string()),
|
||||
publication_status,
|
||||
world_name: "旧公开世界".to_string(),
|
||||
subtitle: String::new(),
|
||||
summary_text: String::new(),
|
||||
theme_mode: CustomWorldThemeMode::Mythic,
|
||||
cover_image_src: None,
|
||||
profile_payload_json: "{}".to_string(),
|
||||
playable_npc_count: 0,
|
||||
landmark_count: 0,
|
||||
play_count: 0,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
author_display_name: "玩家".to_string(),
|
||||
published_at,
|
||||
deleted_at,
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(20),
|
||||
visible,
|
||||
}
|
||||
}
|
||||
|
||||
let legacy_published =
|
||||
build_profile(CustomWorldPublicationStatus::Published, None, None, true);
|
||||
assert!(is_custom_world_profile_publicly_interactive(
|
||||
&legacy_published
|
||||
));
|
||||
assert_eq!(
|
||||
resolve_custom_world_published_at(&legacy_published).to_micros_since_unix_epoch(),
|
||||
20
|
||||
);
|
||||
|
||||
let current_published = build_profile(
|
||||
CustomWorldPublicationStatus::Published,
|
||||
Some(Timestamp::from_micros_since_unix_epoch(10)),
|
||||
None,
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_custom_world_published_at(¤t_published).to_micros_since_unix_epoch(),
|
||||
10
|
||||
);
|
||||
|
||||
assert!(!is_custom_world_profile_publicly_interactive(
|
||||
&build_profile(CustomWorldPublicationStatus::Draft, None, None, true,)
|
||||
));
|
||||
assert!(!is_custom_world_profile_publicly_interactive(
|
||||
&build_profile(
|
||||
CustomWorldPublicationStatus::Published,
|
||||
None,
|
||||
Some(Timestamp::from_micros_since_unix_epoch(30)),
|
||||
true,
|
||||
)
|
||||
));
|
||||
assert!(!is_custom_world_profile_publicly_interactive(
|
||||
&build_profile(CustomWorldPublicationStatus::Published, None, None, false,)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_world_works_hides_compiled_draft_profile_when_agent_session_is_active() {
|
||||
fn build_test_custom_world_profile(
|
||||
|
||||
@@ -1573,11 +1573,6 @@ mod tests {
|
||||
5, 1_000, &existing
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn jump_hop_delete_input_carries_owner_and_profile() {
|
||||
|
||||
@@ -4,6 +4,7 @@ use module_custom_world::CustomWorldPublicationStatus;
|
||||
use module_puzzle::PuzzlePublicationStatus;
|
||||
|
||||
const SOURCE_TYPE_PUZZLE: &str = "puzzle";
|
||||
const SOURCE_TYPE_PUZZLE_CLEAR: &str = "puzzle-clear";
|
||||
const SOURCE_TYPE_CUSTOM_WORLD: &str = "custom-world";
|
||||
const SOURCE_TYPE_JUMP_HOP: &str = "jump-hop";
|
||||
const SOURCE_TYPE_WOODEN_FISH: &str = "wooden-fish";
|
||||
@@ -63,6 +64,7 @@ fn list_work_visibility_tx(
|
||||
|
||||
let mut entries = Vec::new();
|
||||
entries.extend(list_puzzle_work_visibility(ctx));
|
||||
entries.extend(list_puzzle_clear_work_visibility(ctx));
|
||||
entries.extend(list_custom_world_work_visibility(ctx));
|
||||
entries.extend(list_jump_hop_work_visibility(ctx));
|
||||
entries.extend(list_wooden_fish_work_visibility(ctx));
|
||||
@@ -85,6 +87,9 @@ fn update_work_visibility_tx(
|
||||
|
||||
match source_type.as_str() {
|
||||
SOURCE_TYPE_PUZZLE => update_puzzle_work_visibility(ctx, &profile_id, input.visible),
|
||||
SOURCE_TYPE_PUZZLE_CLEAR => {
|
||||
update_puzzle_clear_work_visibility(ctx, &profile_id, input.visible)
|
||||
}
|
||||
SOURCE_TYPE_CUSTOM_WORLD => {
|
||||
update_custom_world_work_visibility(ctx, &profile_id, input.visible)
|
||||
}
|
||||
@@ -167,6 +172,63 @@ fn puzzle_work_visibility_snapshot(row: &PuzzleWorkProfileRow) -> AdminWorkVisib
|
||||
}
|
||||
}
|
||||
|
||||
fn list_puzzle_clear_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
|
||||
ctx.db
|
||||
.puzzle_clear_work_profile()
|
||||
.by_puzzle_clear_work_publication_status()
|
||||
.filter(PUZZLE_CLEAR_PUBLICATION_PUBLISHED)
|
||||
.map(|row| puzzle_clear_work_visibility_snapshot(&row))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn update_puzzle_clear_work_visibility(
|
||||
ctx: &ReducerContext,
|
||||
profile_id: &str,
|
||||
visible: bool,
|
||||
) -> Result<AdminWorkVisibilitySnapshot, String> {
|
||||
let row = ctx
|
||||
.db
|
||||
.puzzle_clear_work_profile()
|
||||
.profile_id()
|
||||
.find(&profile_id.to_string())
|
||||
.ok_or_else(|| "拼消消作品不存在".to_string())?;
|
||||
if row.publication_status != PUZZLE_CLEAR_PUBLICATION_PUBLISHED {
|
||||
return Err("只能修改已发布拼消消作品可见性".to_string());
|
||||
}
|
||||
let next = PuzzleClearWorkProfileRow { visible, ..row };
|
||||
let snapshot = puzzle_clear_work_visibility_snapshot(&next);
|
||||
let profile_id = next.profile_id.clone();
|
||||
ctx.db
|
||||
.puzzle_clear_work_profile()
|
||||
.profile_id()
|
||||
.delete(&profile_id);
|
||||
ctx.db.puzzle_clear_work_profile().insert(next);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn puzzle_clear_work_visibility_snapshot(
|
||||
row: &PuzzleClearWorkProfileRow,
|
||||
) -> AdminWorkVisibilitySnapshot {
|
||||
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
|
||||
AdminWorkVisibilitySnapshot {
|
||||
source_type: SOURCE_TYPE_PUZZLE_CLEAR.to_string(),
|
||||
work_id: row.work_id.clone(),
|
||||
profile_id: row.profile_id.clone(),
|
||||
source_session_id: Some(row.source_session_id.clone()).filter(|value| !value.is_empty()),
|
||||
public_work_code: build_prefixed_public_work_code("PC", &row.profile_id),
|
||||
owner_user_id: row.owner_user_id.clone(),
|
||||
author_display_name: row.author_display_name.clone(),
|
||||
title: choose_non_empty(&[row.work_title.as_str(), row.theme_prompt.as_str(), "拼消消"]),
|
||||
subtitle: "拼消消".to_string(),
|
||||
cover_image_src: Some(row.cover_image_src.clone()).filter(|value| !value.is_empty()),
|
||||
visible: row.visible,
|
||||
published_at_micros: row
|
||||
.published_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
updated_at_micros: sort_time,
|
||||
}
|
||||
}
|
||||
|
||||
fn list_custom_world_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
|
||||
ctx.db
|
||||
.custom_world_profile()
|
||||
|
||||
Reference in New Issue
Block a user