合并 master 并修复架构分支回归

合入 master 最新的认证、玩法契约与推荐页改动。

修复拼图草稿生成、推荐页下一关和公开详情访客试玩回归。

修复抓大鹅草稿试玩鉴权与首屏推荐详情测试入口。

补齐相关测试夹具、文档与团队记忆更新。
This commit is contained in:
2026-06-07 21:35:47 +08:00
80 changed files with 2627 additions and 511 deletions

View File

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

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

View File

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

View File

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

View File

@@ -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 响应字段。

View File

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

View File

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

View File

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

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

@@ -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(&current_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(

View File

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

View File

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