use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum WoodenFishGenerationStatus { Draft, Generating, Ready, Failed, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum WoodenFishActionType { CompileDraft, RegenerateHitObject, GenerateHitSound, ReplaceHitSound, UpdateWorkMeta, UpdateFloatingWords, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum WoodenFishRunStatus { Playing, Finished, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishImageAsset { pub asset_id: String, pub image_src: String, pub image_object_key: String, pub asset_object_id: String, pub generation_provider: String, pub prompt: String, pub width: u32, pub height: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishAudioAsset { pub asset_id: String, pub audio_src: String, pub audio_object_key: String, pub asset_object_id: String, pub source: String, #[serde(default)] pub prompt: Option, #[serde(default)] pub duration_ms: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishWorkspaceCreateRequest { pub template_id: String, pub work_title: String, pub work_description: String, pub theme_tags: Vec, pub hit_object_prompt: String, #[serde(default)] pub hit_object_reference_image_src: Option, #[serde(default)] pub hit_sound_prompt: Option, #[serde(default)] pub hit_sound_asset: Option, pub floating_words: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishActionRequest { pub action_type: WoodenFishActionType, #[serde(default, skip_deserializing)] pub profile_id: Option, #[serde(default)] pub work_title: Option, #[serde(default)] pub work_description: Option, #[serde(default)] pub theme_tags: Option>, #[serde(default)] pub hit_object_prompt: Option, #[serde(default)] pub hit_object_reference_image_src: Option, #[serde(default, skip_deserializing)] pub hit_object_asset: Option, #[serde(default)] #[serde(skip_deserializing)] pub background_asset: Option, #[serde(default)] #[serde(skip_deserializing)] pub back_button_asset: Option, #[serde(default)] pub hit_sound_prompt: Option, #[serde(default)] pub hit_sound_asset: Option, #[serde(default)] pub floating_words: Option>, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishWordCounter { pub text: String, pub count: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishDraftResponse { pub template_id: String, pub template_name: String, #[serde(default)] pub profile_id: Option, pub work_title: String, pub work_description: String, pub theme_tags: Vec, pub hit_object_prompt: String, #[serde(default)] pub hit_object_reference_image_src: Option, #[serde(default)] pub hit_sound_prompt: Option, pub floating_words: Vec, #[serde(default)] pub hit_object_asset: Option, #[serde(default)] pub background_asset: Option, #[serde(default)] pub back_button_asset: Option, #[serde(default)] pub hit_sound_asset: Option, #[serde(default)] pub cover_image_src: Option, pub generation_status: WoodenFishGenerationStatus, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishSessionSnapshotResponse { pub session_id: String, pub owner_user_id: String, pub status: WoodenFishGenerationStatus, #[serde(default)] pub draft: Option, pub created_at: String, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishSessionResponse { pub session: WoodenFishSessionSnapshotResponse, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishActionResponse { pub action_type: WoodenFishActionType, pub session: WoodenFishSessionSnapshotResponse, #[serde(default)] pub work: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishWorkSummaryResponse { pub runtime_kind: String, pub work_id: String, pub profile_id: String, pub owner_user_id: String, #[serde(default)] pub source_session_id: Option, pub work_title: String, pub work_description: String, pub theme_tags: Vec, #[serde(default)] pub cover_image_src: Option, pub publication_status: String, pub play_count: u32, pub updated_at: String, #[serde(default)] pub published_at: Option, pub publish_ready: bool, pub generation_status: WoodenFishGenerationStatus, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishWorkProfileResponse { pub summary: WoodenFishWorkSummaryResponse, pub draft: WoodenFishDraftResponse, pub hit_object_asset: WoodenFishImageAsset, #[serde(default)] pub background_asset: Option, #[serde(default)] pub back_button_asset: Option, pub hit_sound_asset: WoodenFishAudioAsset, pub floating_words: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishWorkDetailResponse { pub item: WoodenFishWorkProfileResponse, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishWorkMutationResponse { pub item: WoodenFishWorkProfileResponse, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishGalleryCardResponse { pub public_work_code: String, pub work_id: String, pub profile_id: String, pub owner_user_id: String, pub author_display_name: String, pub work_title: String, pub work_description: String, #[serde(default)] pub cover_image_src: Option, pub theme_tags: Vec, pub publication_status: String, pub play_count: u32, pub updated_at: String, #[serde(default)] pub published_at: Option, pub generation_status: WoodenFishGenerationStatus, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishGalleryResponse { pub items: Vec, pub has_more: bool, #[serde(default)] pub next_cursor: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishGalleryDetailResponse { pub item: WoodenFishWorkProfileResponse, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishRuntimeRunSnapshotResponse { pub run_id: String, pub profile_id: String, pub owner_user_id: String, pub status: WoodenFishRunStatus, pub total_tap_count: u32, pub word_counters: Vec, pub started_at_ms: u64, pub updated_at_ms: u64, #[serde(default)] pub finished_at_ms: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishRunResponse { pub run: WoodenFishRuntimeRunSnapshotResponse, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishStartRunRequest { pub profile_id: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishCheckpointRunRequest { pub total_tap_count: u32, pub word_counters: Vec, pub client_event_id: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishFinishRunRequest { pub total_tap_count: u32, pub word_counters: Vec, pub client_event_id: String, } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn wooden_fish_workspace_request_uses_camel_case_and_default_words() { let payload = serde_json::to_value(WoodenFishWorkspaceCreateRequest { template_id: "wooden-fish".to_string(), work_title: "今日敲木鱼".to_string(), work_description: "轻松敲击".to_string(), theme_tags: vec!["休闲".to_string()], hit_object_prompt: "卡通木鱼".to_string(), hit_object_reference_image_src: None, hit_sound_prompt: Some("清脆木鱼声".to_string()), hit_sound_asset: None, floating_words: vec![ "幸运".to_string(), "健康".to_string(), "财富".to_string(), "姻缘".to_string(), "幸福".to_string(), "事业".to_string(), "成功".to_string(), "功德".to_string(), ], }) .expect("payload should serialize"); assert_eq!(payload["templateId"], json!("wooden-fish")); assert_eq!(payload["hitObjectPrompt"], json!("卡通木鱼")); assert_eq!(payload["hitSoundPrompt"], json!("清脆木鱼声")); assert_eq!(payload["floatingWords"][7], json!("功德")); } #[test] fn wooden_fish_runtime_snapshot_counts_words_inside_one_run() { let payload = serde_json::to_value(WoodenFishRuntimeRunSnapshotResponse { run_id: "wooden-fish-run-1".to_string(), profile_id: "wooden-fish-profile-1".to_string(), owner_user_id: "user-1".to_string(), status: WoodenFishRunStatus::Playing, total_tap_count: 3, word_counters: vec![ WoodenFishWordCounter { text: "幸运".to_string(), count: 2, }, WoodenFishWordCounter { text: "功德".to_string(), count: 1, }, ], started_at_ms: 100, updated_at_ms: 130, finished_at_ms: None, }) .expect("payload should serialize"); assert_eq!(payload["status"], json!("playing")); assert_eq!(payload["totalTapCount"], json!(3)); assert_eq!(payload["wordCounters"][0]["text"], json!("幸运")); } #[test] fn wooden_fish_action_request_serializes_audio_and_image_fields() { let payload = serde_json::to_value(WoodenFishActionRequest { action_type: WoodenFishActionType::ReplaceHitSound, profile_id: Some("wooden-fish-profile-1".to_string()), work_title: None, work_description: None, theme_tags: None, hit_object_prompt: Some("卡通铜钹".to_string()), hit_object_reference_image_src: Some("/uploads/reference.png".to_string()), hit_object_asset: Some(WoodenFishImageAsset { asset_id: "image-1".to_string(), image_src: "/generated-wooden-fish-assets/profile/hit-object/image.png".to_string(), image_object_key: "generated-wooden-fish-assets/profile/hit-object/image.png" .to_string(), asset_object_id: "image-object-1".to_string(), generation_provider: "image2".to_string(), prompt: "卡通铜钹".to_string(), width: 1024, height: 1024, }), background_asset: Some(WoodenFishImageAsset { asset_id: "background-1".to_string(), image_src: "/generated-wooden-fish-assets/profile/background/image.png".to_string(), image_object_key: "generated-wooden-fish-assets/profile/background/image.png" .to_string(), asset_object_id: "background-object-1".to_string(), generation_provider: "image2".to_string(), prompt: "赛博莲花背景".to_string(), width: 1024, height: 1536, }), back_button_asset: Some(WoodenFishImageAsset { asset_id: "back-button-1".to_string(), image_src: "/generated-wooden-fish-assets/profile/back-button/image.png" .to_string(), image_object_key: "generated-wooden-fish-assets/profile/back-button/image.png" .to_string(), asset_object_id: "back-button-object-1".to_string(), generation_provider: "image2".to_string(), prompt: "赛博莲花返回按钮".to_string(), width: 1024, height: 1024, }), hit_sound_prompt: Some("短促木鱼声".to_string()), hit_sound_asset: Some(WoodenFishAudioAsset { asset_id: "sound-1".to_string(), audio_src: "/generated/wooden-fish.mp3".to_string(), audio_object_key: "generated/wooden-fish.mp3".to_string(), asset_object_id: "asset-object-1".to_string(), source: "upload".to_string(), prompt: None, duration_ms: Some(800), }), floating_words: Some(vec!["功德".to_string()]), }) .expect("payload should serialize"); assert_eq!(payload["actionType"], json!("replace-hit-sound")); assert_eq!(payload["profileId"], json!("wooden-fish-profile-1")); assert_eq!(payload["hitObjectPrompt"], json!("卡通铜钹")); assert_eq!( payload["hitObjectAsset"]["imageObjectKey"], json!("generated-wooden-fish-assets/profile/hit-object/image.png") ); assert_eq!(payload["backgroundAsset"]["height"], json!(1536)); assert_eq!(payload["backButtonAsset"]["width"], json!(1024)); assert_eq!(payload["hitSoundAsset"]["source"], json!("upload")); assert_eq!(payload["hitSoundAsset"]["durationMs"], json!(800)); } #[test] fn wooden_fish_action_request_ignores_client_hit_object_asset() { let payload = serde_json::from_value::(json!({ "actionType": "compile-draft", "hitObjectPrompt": "卡通铜钹", "hitObjectAsset": { "assetId": "client-image", "imageSrc": "/generated-wooden-fish-assets/client/image.png", "imageObjectKey": "generated-wooden-fish-assets/client/image.png", "assetObjectId": "client-asset-object", "generationProvider": "client", "prompt": "跳过生成", "width": 1024, "height": 1024 } })) .expect("payload should deserialize"); assert_eq!(payload.action_type, WoodenFishActionType::CompileDraft); assert_eq!(payload.hit_object_prompt.as_deref(), Some("卡通铜钹")); assert_eq!(payload.hit_object_asset, None); } #[test] fn wooden_fish_work_profile_keeps_summary_and_runtime_assets() { let image = WoodenFishImageAsset { asset_id: "image-1".to_string(), image_src: "/generated/wooden-fish.png".to_string(), image_object_key: "generated/wooden-fish.png".to_string(), asset_object_id: "image-object-1".to_string(), generation_provider: "image2".to_string(), prompt: "卡通木鱼".to_string(), width: 1024, height: 1024, }; let audio = WoodenFishAudioAsset { asset_id: "sound-1".to_string(), audio_src: "/generated/wooden-fish.mp3".to_string(), audio_object_key: "generated/wooden-fish.mp3".to_string(), asset_object_id: "sound-object-1".to_string(), source: "generated".to_string(), prompt: Some("清脆木鱼".to_string()), duration_ms: Some(600), }; let back_button = WoodenFishImageAsset { asset_id: "back-button-1".to_string(), image_src: "/generated/wooden-fish-back-button.png".to_string(), image_object_key: "generated/wooden-fish-back-button.png".to_string(), asset_object_id: "back-button-object-1".to_string(), generation_provider: "image2".to_string(), prompt: "主题返回按钮".to_string(), width: 1024, height: 1024, }; let profile = WoodenFishWorkProfileResponse { summary: WoodenFishWorkSummaryResponse { runtime_kind: "wooden-fish".to_string(), work_id: "wooden-fish-profile-1".to_string(), profile_id: "wooden-fish-profile-1".to_string(), owner_user_id: "user-1".to_string(), source_session_id: Some("wooden-fish-session-1".to_string()), work_title: "敲木鱼".to_string(), work_description: String::new(), theme_tags: vec!["休闲".to_string()], cover_image_src: Some(image.image_src.clone()), publication_status: "draft".to_string(), play_count: 0, updated_at: "2026-05-20T00:00:00Z".to_string(), published_at: None, publish_ready: true, generation_status: WoodenFishGenerationStatus::Ready, }, draft: WoodenFishDraftResponse { template_id: "wooden-fish".to_string(), template_name: "敲木鱼".to_string(), profile_id: Some("wooden-fish-profile-1".to_string()), work_title: "敲木鱼".to_string(), work_description: String::new(), theme_tags: vec!["休闲".to_string()], hit_object_prompt: "卡通木鱼".to_string(), hit_object_reference_image_src: None, hit_sound_prompt: Some("清脆木鱼".to_string()), floating_words: vec!["功德".to_string()], hit_object_asset: Some(image.clone()), background_asset: None, back_button_asset: Some(back_button.clone()), hit_sound_asset: Some(audio.clone()), cover_image_src: Some(image.image_src.clone()), generation_status: WoodenFishGenerationStatus::Ready, }, hit_object_asset: image, background_asset: None, back_button_asset: Some(back_button), hit_sound_asset: audio, floating_words: vec!["功德".to_string()], }; let payload = serde_json::to_value(profile).expect("profile should serialize"); assert_eq!(payload["summary"]["runtimeKind"], json!("wooden-fish")); assert_eq!( payload["hitObjectAsset"]["generationProvider"], json!("image2") ); assert_eq!(payload["hitSoundAsset"]["source"], json!("generated")); assert_eq!( payload["backButtonAsset"]["imageSrc"], json!("/generated/wooden-fish-back-button.png") ); } }