pub(crate) mod tables; mod types; pub use tables::*; pub use types::*; use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; use module_wooden_fish::{ WoodenFishRunSnapshot, WoodenFishRunStatus, WoodenFishWordCounter, apply_run_checkpoint, default_floating_words, finish_run, normalize_floating_words, normalize_word_counters, }; use serde::Serialize; use serde::de::DeserializeOwned; use spacetimedb::AnonymousViewContext; #[spacetimedb::view(accessor = wooden_fish_gallery_view, public)] pub fn wooden_fish_gallery_view(ctx: &AnonymousViewContext) -> Vec { let mut items = ctx .db .wooden_fish_work_profile() .by_wooden_fish_work_publication_status() .filter(WOODEN_FISH_PUBLICATION_PUBLISHED) .filter_map(|row| match build_gallery_view_row(&row) { Ok(item) => Some(item), Err(error) => { log::warn!( "敲木鱼公开广场 view 跳过损坏的作品投影 profile_id={}: {}", row.profile_id, error ); None } }) .collect::>(); items.sort_by(|left, right| { right .updated_at_micros .cmp(&left.updated_at_micros) .then_with(|| left.profile_id.cmp(&right.profile_id)) }); items } #[spacetimedb::view(accessor = wooden_fish_gallery_card_view, public)] pub fn wooden_fish_gallery_card_view( ctx: &AnonymousViewContext, ) -> Vec { wooden_fish_gallery_view(ctx) .into_iter() .map(|row| WoodenFishGalleryCardViewRow { public_work_code: row.public_work_code, work_id: row.work_id, profile_id: row.profile_id, owner_user_id: row.owner_user_id, author_display_name: row.author_display_name, work_title: row.work_title, work_description: row.work_description, cover_image_src: row.cover_image_src, theme_tags: row.theme_tags, publication_status: row.publication_status, play_count: row.play_count, updated_at_micros: row.updated_at_micros, published_at_micros: row.published_at_micros, generation_status: row.generation_status, }) .collect() } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct WoodenFishGalleryViewRow { pub public_work_code: String, pub work_id: String, pub profile_id: String, pub owner_user_id: String, pub source_session_id: String, pub author_display_name: String, pub work_title: String, pub work_description: String, pub theme_tags: Vec, pub hit_object_prompt: String, pub hit_object_reference_image_src: Option, pub hit_sound_prompt: Option, pub hit_object_asset: Option, pub background_asset: Option, pub hit_sound_asset: Option, pub floating_words: Vec, pub cover_image_src: String, pub publication_status: String, pub publish_ready: bool, pub play_count: u32, pub generation_status: String, pub updated_at_micros: i64, pub published_at_micros: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct WoodenFishGalleryCardViewRow { 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, pub cover_image_src: String, pub theme_tags: Vec, pub publication_status: String, pub play_count: u32, pub updated_at_micros: i64, pub published_at_micros: Option, pub generation_status: String, } #[spacetimedb::procedure] pub fn create_wooden_fish_agent_session( ctx: &mut ProcedureContext, input: WoodenFishAgentSessionCreateInput, ) -> WoodenFishAgentSessionProcedureResult { match ctx.try_with_tx(|tx| create_wooden_fish_agent_session_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn get_wooden_fish_agent_session( ctx: &mut ProcedureContext, input: WoodenFishAgentSessionGetInput, ) -> WoodenFishAgentSessionProcedureResult { match ctx.try_with_tx(|tx| get_wooden_fish_agent_session_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn compile_wooden_fish_draft( ctx: &mut ProcedureContext, input: WoodenFishDraftCompileInput, ) -> WoodenFishAgentSessionProcedureResult { match ctx.try_with_tx(|tx| compile_wooden_fish_draft_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn get_wooden_fish_work_profile( ctx: &mut ProcedureContext, input: WoodenFishWorkGetInput, ) -> WoodenFishWorkProcedureResult { match ctx.try_with_tx(|tx| get_wooden_fish_work_profile_tx(tx, input.clone())) { Ok(work) => work_result(work), Err(message) => work_error(message), } } #[spacetimedb::procedure] pub fn update_wooden_fish_work( ctx: &mut ProcedureContext, input: WoodenFishWorkUpdateInput, ) -> WoodenFishWorkProcedureResult { match ctx.try_with_tx(|tx| update_wooden_fish_work_tx(tx, input.clone())) { Ok(work) => work_result(work), Err(message) => work_error(message), } } #[spacetimedb::procedure] pub fn publish_wooden_fish_work( ctx: &mut ProcedureContext, input: WoodenFishWorkPublishInput, ) -> WoodenFishWorkProcedureResult { match ctx.try_with_tx(|tx| publish_wooden_fish_work_tx(tx, input.clone())) { Ok(work) => work_result(work), Err(message) => work_error(message), } } #[spacetimedb::procedure] pub fn list_wooden_fish_works( ctx: &mut ProcedureContext, input: WoodenFishWorksListInput, ) -> WoodenFishWorksProcedureResult { match ctx.try_with_tx(|tx| list_wooden_fish_works_tx(tx, input.clone())) { Ok(items) => WoodenFishWorksProcedureResult { ok: true, items, error_message: None, }, Err(message) => WoodenFishWorksProcedureResult { ok: false, items: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn start_wooden_fish_run( ctx: &mut ProcedureContext, input: WoodenFishRunStartInput, ) -> WoodenFishRunProcedureResult { match ctx.try_with_tx(|tx| start_wooden_fish_run_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn get_wooden_fish_run( ctx: &mut ProcedureContext, input: WoodenFishRunGetInput, ) -> WoodenFishRunProcedureResult { match ctx.try_with_tx(|tx| get_wooden_fish_run_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn checkpoint_wooden_fish_run( ctx: &mut ProcedureContext, input: WoodenFishRunCheckpointInput, ) -> WoodenFishRunProcedureResult { match ctx.try_with_tx(|tx| checkpoint_wooden_fish_run_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn finish_wooden_fish_run( ctx: &mut ProcedureContext, input: WoodenFishRunFinishInput, ) -> WoodenFishRunProcedureResult { match ctx.try_with_tx(|tx| finish_wooden_fish_run_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } fn create_wooden_fish_agent_session_tx( ctx: &ReducerContext, input: WoodenFishAgentSessionCreateInput, ) -> Result { require_non_empty(&input.session_id, "wooden_fish session_id")?; require_non_empty(&input.owner_user_id, "wooden_fish owner_user_id")?; if ctx .db .wooden_fish_agent_session() .session_id() .find(&input.session_id) .is_some() { return Err("wooden_fish_agent_session.session_id 已存在".to_string()); } let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); let config = input .config_json .as_deref() .map(parse_config) .transpose()? .unwrap_or_else(|| default_config_from_input(&input)); let draft = input .draft_json .as_deref() .map(parse_json) .transpose()? .unwrap_or_else(|| draft_from_config(&config, None, WOODEN_FISH_GENERATION_DRAFT)); ctx.db .wooden_fish_agent_session() .insert(WoodenFishAgentSessionRow { session_id: input.session_id.clone(), owner_user_id: input.owner_user_id.clone(), current_turn: 0, progress_percent: 0, stage: WOODEN_FISH_STAGE_COLLECTING.to_string(), config_json: to_json_string(&config), draft_json: to_json_string(&draft), published_profile_id: String::new(), created_at, updated_at: created_at, }); get_wooden_fish_agent_session_tx( ctx, WoodenFishAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn get_wooden_fish_agent_session_tx( ctx: &ReducerContext, input: WoodenFishAgentSessionGetInput, ) -> Result { let row = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; build_session_snapshot(&row) } fn compile_wooden_fish_draft_tx( ctx: &ReducerContext, input: WoodenFishDraftCompileInput, ) -> Result { require_non_empty(&input.profile_id, "wooden_fish profile_id")?; let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; let tags = parse_tags(input.theme_tags_json.as_deref().unwrap_or("[]"))?; let floating_words = match input .floating_words_json .as_deref() .map(parse_json::>) .transpose()? { Some(words) => normalize_floating_words(&words), None => default_floating_words(), }; let hit_object_asset = input .hit_object_asset_json .as_deref() .map(parse_json) .transpose()?; let hit_sound_asset = input .hit_sound_asset_json .as_deref() .map(parse_json) .transpose()?; let background_asset = input .background_asset_json .as_deref() .map(parse_json) .transpose()?; let cover_image_src = input .cover_image_src .as_deref() .and_then(clean_optional) .or_else(|| { hit_object_asset .as_ref() .map(|asset: &WoodenFishImageAssetSnapshot| asset.image_src.clone()) }); let draft = WoodenFishDraftSnapshot { template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), profile_id: Some(input.profile_id.clone()), work_title: clean_string(&input.work_title, WOODEN_FISH_TEMPLATE_NAME), work_description: input.work_description.trim().to_string(), theme_tags: tags.clone(), hit_object_prompt: clean_string( &input.hit_object_prompt, "默认敲击物图案,圆润木质质感,透明背景", ), hit_object_reference_image_src: input .hit_object_reference_image_src .as_deref() .and_then(clean_optional), hit_sound_prompt: input.hit_sound_prompt.as_deref().and_then(clean_optional), floating_words: floating_words.clone(), hit_object_asset: hit_object_asset.clone(), background_asset: background_asset.clone(), hit_sound_asset: hit_sound_asset.clone(), cover_image_src: cover_image_src.clone(), generation_status: input .generation_status .clone() .unwrap_or_else(|| WOODEN_FISH_GENERATION_READY.to_string()), }; let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); let row = WoodenFishWorkProfileRow { profile_id: input.profile_id.clone(), work_id: input.profile_id.clone(), owner_user_id: input.owner_user_id.clone(), source_session_id: input.session_id.clone(), author_display_name: clean_string(&input.author_display_name, "敲木鱼玩家"), work_title: draft.work_title.clone(), work_description: draft.work_description.clone(), theme_tags_json: to_json_string(&tags), hit_object_prompt: draft.hit_object_prompt.clone(), hit_object_reference_image_src: draft .hit_object_reference_image_src .clone() .unwrap_or_default(), hit_sound_prompt: draft.hit_sound_prompt.clone().unwrap_or_default(), hit_object_asset_json: hit_object_asset .as_ref() .map(to_json_string) .unwrap_or_default(), hit_sound_asset_json: hit_sound_asset .as_ref() .map(to_json_string) .unwrap_or_default(), floating_words_json: to_json_string(&floating_words), cover_image_src: cover_image_src.unwrap_or_default(), generation_status: draft.generation_status.clone(), publication_status: WOODEN_FISH_PUBLICATION_DRAFT.to_string(), play_count: 0, updated_at: compiled_at, published_at: None, background_asset_json: background_asset.as_ref().map(to_json_string), }; upsert_work(ctx, row); let config = config_from_draft(&draft); replace_session( ctx, &session, WoodenFishAgentSessionRow { progress_percent: 100, stage: WOODEN_FISH_STAGE_DRAFT_COMPILED.to_string(), config_json: to_json_string(&config), draft_json: to_json_string(&draft), published_profile_id: input.profile_id, updated_at: compiled_at, ..clone_session(&session) }, ); get_wooden_fish_agent_session_tx( ctx, WoodenFishAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn get_wooden_fish_work_profile_tx( ctx: &ReducerContext, input: WoodenFishWorkGetInput, ) -> Result { let row = find_work(ctx, &input.profile_id)?; if !input.owner_user_id.trim().is_empty() && row.owner_user_id != input.owner_user_id { return Err("无权访问该 wooden_fish work".to_string()); } build_work_snapshot(&row) } fn update_wooden_fish_work_tx( ctx: &ReducerContext, input: WoodenFishWorkUpdateInput, ) -> Result { let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let mut next = clone_work(&row); next.work_title = clean_string(&input.work_title, &row.work_title); next.work_description = input.work_description.trim().to_string(); next.theme_tags_json = input.theme_tags_json.clone(); if let Some(value) = input.hit_object_prompt.as_deref().and_then(clean_optional) { next.hit_object_prompt = value; } if let Some(value) = input .hit_object_reference_image_src .as_deref() .and_then(clean_optional) { next.hit_object_reference_image_src = value; } if let Some(value) = input.hit_sound_prompt.as_deref().and_then(clean_optional) { next.hit_sound_prompt = value; } if let Some(value) = input .hit_object_asset_json .as_deref() .and_then(clean_optional) { let asset = parse_json::(&value)?; next.hit_object_asset_json = to_json_string(&asset); next.cover_image_src = asset.image_src; } if let Some(value) = input .hit_sound_asset_json .as_deref() .and_then(clean_optional) { let asset = parse_json::(&value)?; next.hit_sound_asset_json = to_json_string(&asset); } if let Some(value) = input .background_asset_json .as_deref() .and_then(clean_optional) { let asset = parse_json::(&value)?; next.background_asset_json = Some(to_json_string(&asset)); } if let Some(value) = input .floating_words_json .as_deref() .and_then(clean_optional) { let words = parse_json::>(&value)?; next.floating_words_json = to_json_string(&normalize_floating_words(&words)); } if let Some(value) = input.cover_image_src.as_deref().and_then(clean_optional) { next.cover_image_src = value; } if let Some(value) = input.generation_status.as_deref().and_then(clean_optional) { next.generation_status = value; } next.updated_at = updated_at; replace_work(ctx, &row, next); let updated = find_work(ctx, &row.profile_id)?; sync_session_from_work_update(ctx, &updated, updated_at)?; build_work_snapshot(&updated) } fn publish_wooden_fish_work_tx( ctx: &ReducerContext, input: WoodenFishWorkPublishInput, ) -> Result { let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; if !is_publish_ready(&row) { return Err("发布需要完整的敲击物图案、敲击音效和飘字配置".to_string()); } let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); replace_work( ctx, &row, WoodenFishWorkProfileRow { publication_status: WOODEN_FISH_PUBLICATION_PUBLISHED.to_string(), updated_at: published_at, published_at: Some(published_at), ..clone_work(&row) }, ); if let Some(session) = ctx .db .wooden_fish_agent_session() .session_id() .find(&row.source_session_id) { replace_session( ctx, &session, WoodenFishAgentSessionRow { stage: WOODEN_FISH_STAGE_PUBLISHED.to_string(), updated_at: published_at, ..clone_session(&session) }, ); } let updated = find_work(ctx, &row.profile_id)?; build_work_snapshot(&updated) } fn list_wooden_fish_works_tx( ctx: &ReducerContext, input: WoodenFishWorksListInput, ) -> Result, String> { let mut rows = if input.owner_user_id.trim().is_empty() { ctx.db.wooden_fish_work_profile().iter().collect::>() } else { ctx.db .wooden_fish_work_profile() .by_wooden_fish_work_owner_user_id() .filter(input.owner_user_id.as_str()) .collect::>() }; if input.published_only { rows.retain(|row| row.publication_status == WOODEN_FISH_PUBLICATION_PUBLISHED); } rows.sort_by(|left, right| { right .updated_at .cmp(&left.updated_at) .then_with(|| left.profile_id.cmp(&right.profile_id)) }); rows.into_iter() .map(|row| build_work_snapshot(&row)) .collect() } fn start_wooden_fish_run_tx( ctx: &ReducerContext, input: WoodenFishRunStartInput, ) -> Result { require_non_empty(&input.run_id, "wooden_fish run_id")?; let work = find_work(ctx, &input.profile_id)?; if !is_publish_ready(&work) { return Err("敲木鱼运行态需要完整作品配置".to_string()); } let snapshot = WoodenFishRunSnapshot { run_id: input.run_id.clone(), profile_id: input.profile_id.clone(), owner_user_id: input.owner_user_id.clone(), status: WoodenFishRunStatus::Playing, total_tap_count: 0, word_counters: Vec::new(), started_at_ms: input.started_at_ms.max(0) as u64, updated_at_ms: input.started_at_ms.max(0) as u64, finished_at_ms: None, }; upsert_run(ctx, &snapshot, input.started_at_ms); increment_work_play_count(ctx, &work, input.started_at_ms); insert_event( ctx, input.client_event_id, input.owner_user_id, input.profile_id, input.run_id, WOODEN_FISH_EVENT_RUN_STARTED, None, input.started_at_ms, ); Ok(snapshot) } fn get_wooden_fish_run_tx( ctx: &ReducerContext, input: WoodenFishRunGetInput, ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; parse_json(&row.snapshot_json) } fn checkpoint_wooden_fish_run_tx( ctx: &ReducerContext, input: WoodenFishRunCheckpointInput, ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; let current = parse_json::(&row.snapshot_json)?; if current.status == WoodenFishRunStatus::Finished { return Err("wooden_fish run 已结束,不能 checkpoint".to_string()); } let counters = parse_json::>(&input.word_counters_json)?; let next = apply_run_checkpoint( ¤t, input.total_tap_count, normalize_word_counters(counters), input.checkpoint_at_ms.max(0) as u64, ); replace_run(ctx, &row, &next, input.checkpoint_at_ms); insert_event( ctx, input.client_event_id, input.owner_user_id, next.profile_id.clone(), input.run_id, WOODEN_FISH_EVENT_RUN_CHECKPOINT, Some(next.total_tap_count.to_string()), input.checkpoint_at_ms, ); Ok(next) } fn finish_wooden_fish_run_tx( ctx: &ReducerContext, input: WoodenFishRunFinishInput, ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; let current = parse_json::(&row.snapshot_json)?; let counters = parse_json::>(&input.word_counters_json)?; let next = finish_run( ¤t, input.total_tap_count, normalize_word_counters(counters), input.finished_at_ms.max(0) as u64, ); replace_run(ctx, &row, &next, input.finished_at_ms); insert_event( ctx, input.client_event_id, input.owner_user_id, next.profile_id.clone(), input.run_id, WOODEN_FISH_EVENT_RUN_FINISHED, Some(next.total_tap_count.to_string()), input.finished_at_ms, ); Ok(next) } fn build_gallery_view_row( row: &WoodenFishWorkProfileRow, ) -> Result { let work = build_work_snapshot(row)?; Ok(WoodenFishGalleryViewRow { public_work_code: build_wooden_fish_public_work_code(&work.profile_id), work_id: work.work_id, profile_id: work.profile_id, owner_user_id: work.owner_user_id, source_session_id: work.source_session_id, author_display_name: work.author_display_name, work_title: work.work_title, work_description: work.work_description, theme_tags: work.theme_tags, hit_object_prompt: work.hit_object_prompt, hit_object_reference_image_src: work.hit_object_reference_image_src, hit_sound_prompt: work.hit_sound_prompt, hit_object_asset: work.hit_object_asset, background_asset: work.background_asset, hit_sound_asset: work.hit_sound_asset, floating_words: work.floating_words, cover_image_src: work.cover_image_src, publication_status: work.publication_status, publish_ready: work.publish_ready, play_count: work.play_count, generation_status: work.generation_status, updated_at_micros: work.updated_at_micros, published_at_micros: work.published_at_micros, }) } fn build_session_snapshot( row: &WoodenFishAgentSessionRow, ) -> Result { Ok(WoodenFishAgentSessionSnapshot { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), current_turn: row.current_turn, progress_percent: row.progress_percent, stage: row.stage.clone(), config: parse_config(&row.config_json)?, draft: clean_optional(&row.draft_json) .map(|value| parse_json(&value)) .transpose()?, published_profile_id: clean_optional(&row.published_profile_id), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), }) } fn build_work_snapshot(row: &WoodenFishWorkProfileRow) -> Result { Ok(WoodenFishWorkSnapshot { work_id: row.work_id.clone(), profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), work_title: row.work_title.clone(), work_description: row.work_description.clone(), theme_tags: parse_tags(&row.theme_tags_json)?, hit_object_prompt: row.hit_object_prompt.clone(), hit_object_reference_image_src: clean_optional(&row.hit_object_reference_image_src), hit_sound_prompt: clean_optional(&row.hit_sound_prompt), hit_object_asset: clean_optional(&row.hit_object_asset_json) .map(|value| parse_json(&value)) .transpose()?, background_asset: row .background_asset_json .as_deref() .and_then(clean_optional) .map(|value| parse_json(&value)) .transpose()?, hit_sound_asset: clean_optional(&row.hit_sound_asset_json) .map(|value| parse_json(&value)) .transpose()?, floating_words: normalize_floating_words(&parse_json_or_default::>( &row.floating_words_json, )), cover_image_src: row.cover_image_src.clone(), publication_status: row.publication_status.clone(), publish_ready: is_publish_ready(row), play_count: row.play_count, generation_status: row.generation_status.clone(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), }) } fn sync_session_from_work_update( ctx: &ReducerContext, work: &WoodenFishWorkProfileRow, updated_at: Timestamp, ) -> Result<(), String> { let Some(session) = ctx .db .wooden_fish_agent_session() .session_id() .find(&work.source_session_id) else { return Ok(()); }; let snapshot = build_work_snapshot(work)?; let draft = draft_from_work_snapshot(&snapshot); let config = config_from_draft(&draft); replace_session( ctx, &session, WoodenFishAgentSessionRow { config_json: to_json_string(&config), draft_json: to_json_string(&draft), updated_at, ..clone_session(&session) }, ); Ok(()) } fn find_owned_session( ctx: &ReducerContext, session_id: &str, owner_user_id: &str, ) -> Result { let row = ctx .db .wooden_fish_agent_session() .session_id() .find(&session_id.to_string()) .ok_or_else(|| "wooden_fish_agent_session 不存在".to_string())?; if row.owner_user_id != owner_user_id { return Err("无权访问该 wooden_fish session".to_string()); } Ok(row) } fn find_work(ctx: &ReducerContext, profile_id: &str) -> Result { ctx.db .wooden_fish_work_profile() .profile_id() .find(&profile_id.to_string()) .ok_or_else(|| "wooden_fish_work_profile 不存在".to_string()) } fn find_owned_work( ctx: &ReducerContext, profile_id: &str, owner_user_id: &str, ) -> Result { let row = find_work(ctx, profile_id)?; if row.owner_user_id != owner_user_id { return Err("无权访问该 wooden_fish work".to_string()); } Ok(row) } fn find_owned_run( ctx: &ReducerContext, run_id: &str, owner_user_id: &str, ) -> Result { let row = ctx .db .wooden_fish_runtime_run() .run_id() .find(&run_id.to_string()) .ok_or_else(|| "wooden_fish_runtime_run 不存在".to_string())?; if row.owner_user_id != owner_user_id { return Err("无权访问该 wooden_fish run".to_string()); } Ok(row) } fn upsert_work(ctx: &ReducerContext, row: WoodenFishWorkProfileRow) { if let Some(old) = ctx .db .wooden_fish_work_profile() .profile_id() .find(&row.profile_id) { ctx.db.wooden_fish_work_profile().delete(old); } ctx.db.wooden_fish_work_profile().insert(row); } fn replace_work( ctx: &ReducerContext, old: &WoodenFishWorkProfileRow, next: WoodenFishWorkProfileRow, ) { ctx.db.wooden_fish_work_profile().delete(clone_work(old)); ctx.db.wooden_fish_work_profile().insert(next); } fn replace_session( ctx: &ReducerContext, old: &WoodenFishAgentSessionRow, next: WoodenFishAgentSessionRow, ) { ctx.db .wooden_fish_agent_session() .delete(clone_session(old)); ctx.db.wooden_fish_agent_session().insert(next); } fn upsert_run(ctx: &ReducerContext, snapshot: &WoodenFishRunSnapshot, updated_at_ms: i64) { if let Some(old) = ctx .db .wooden_fish_runtime_run() .run_id() .find(&snapshot.run_id) { ctx.db.wooden_fish_runtime_run().delete(old); } let created_at = Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)); ctx.db .wooden_fish_runtime_run() .insert(run_row_from_snapshot(snapshot, created_at, created_at)); } fn replace_run( ctx: &ReducerContext, old: &WoodenFishRuntimeRunRow, snapshot: &WoodenFishRunSnapshot, updated_at_ms: i64, ) { ctx.db.wooden_fish_runtime_run().delete(clone_run(old)); ctx.db .wooden_fish_runtime_run() .insert(run_row_from_snapshot( snapshot, old.created_at, Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)), )); } fn run_row_from_snapshot( snapshot: &WoodenFishRunSnapshot, created_at: Timestamp, updated_at: Timestamp, ) -> WoodenFishRuntimeRunRow { WoodenFishRuntimeRunRow { run_id: snapshot.run_id.clone(), owner_user_id: snapshot.owner_user_id.clone(), profile_id: snapshot.profile_id.clone(), status: run_status_as_str(snapshot.status).to_string(), total_tap_count: snapshot.total_tap_count, word_counters_json: to_json_string(&snapshot.word_counters), started_at_ms: snapshot.started_at_ms as i64, updated_at_ms: snapshot.updated_at_ms as i64, finished_at_ms: snapshot .finished_at_ms .map(|value| value as i64) .unwrap_or(0), snapshot_json: to_json_string(snapshot), created_at, updated_at, } } fn increment_work_play_count( ctx: &ReducerContext, row: &WoodenFishWorkProfileRow, played_at_ms: i64, ) { replace_work( ctx, row, WoodenFishWorkProfileRow { play_count: row.play_count.saturating_add(1), updated_at: Timestamp::from_micros_since_unix_epoch(played_at_ms.saturating_mul(1000)), ..clone_work(row) }, ); } fn insert_event( ctx: &ReducerContext, event_id: String, owner_user_id: String, profile_id: String, run_id: String, event_type: &str, result: Option, occurred_at_ms: i64, ) { let event_id = clean_optional(&event_id).unwrap_or_else(|| { format!( "wooden-fish-event-{}-{}-{}", run_id, event_type, occurred_at_ms ) }); if ctx .db .wooden_fish_event() .event_id() .find(&event_id) .is_some() { return; } ctx.db.wooden_fish_event().insert(WoodenFishEventRow { event_id, owner_user_id, profile_id, run_id, event_type: event_type.to_string(), result: result.unwrap_or_default(), occurred_at: Timestamp::from_micros_since_unix_epoch(occurred_at_ms.saturating_mul(1000)), }); } fn is_publish_ready(row: &WoodenFishWorkProfileRow) -> bool { !row.work_title.trim().is_empty() && !row.hit_object_asset_json.trim().is_empty() && row .background_asset_json .as_deref() .and_then(clean_optional) .is_some() && !row.hit_sound_asset_json.trim().is_empty() && !row.floating_words_json.trim().is_empty() && row.generation_status == WOODEN_FISH_GENERATION_READY } fn default_config_from_input( input: &WoodenFishAgentSessionCreateInput, ) -> WoodenFishCreatorConfigSnapshot { WoodenFishCreatorConfigSnapshot { work_title: clean_string(&input.work_title, WOODEN_FISH_TEMPLATE_NAME), work_description: input.work_description.trim().to_string(), theme_tags: parse_tags(input.theme_tags_json.as_deref().unwrap_or("[]")) .unwrap_or_default(), hit_object_prompt: "默认敲击物图案,圆润木质质感,透明背景".to_string(), hit_object_reference_image_src: None, hit_sound_prompt: Some("清脆短促的木鱼敲击声".to_string()), floating_words: default_floating_words(), } } fn draft_from_config( config: &WoodenFishCreatorConfigSnapshot, profile_id: Option, generation_status: &str, ) -> WoodenFishDraftSnapshot { WoodenFishDraftSnapshot { template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), profile_id, work_title: config.work_title.clone(), work_description: config.work_description.clone(), theme_tags: config.theme_tags.clone(), hit_object_prompt: config.hit_object_prompt.clone(), hit_object_reference_image_src: config.hit_object_reference_image_src.clone(), hit_sound_prompt: config.hit_sound_prompt.clone(), floating_words: normalize_floating_words(&config.floating_words), hit_object_asset: None, background_asset: None, hit_sound_asset: None, cover_image_src: None, generation_status: generation_status.to_string(), } } fn draft_from_work_snapshot(work: &WoodenFishWorkSnapshot) -> WoodenFishDraftSnapshot { WoodenFishDraftSnapshot { template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), profile_id: Some(work.profile_id.clone()), work_title: work.work_title.clone(), work_description: work.work_description.clone(), theme_tags: work.theme_tags.clone(), hit_object_prompt: work.hit_object_prompt.clone(), hit_object_reference_image_src: work.hit_object_reference_image_src.clone(), hit_sound_prompt: work.hit_sound_prompt.clone(), floating_words: work.floating_words.clone(), hit_object_asset: work.hit_object_asset.clone(), background_asset: work.background_asset.clone(), hit_sound_asset: work.hit_sound_asset.clone(), cover_image_src: clean_optional(&work.cover_image_src), generation_status: work.generation_status.clone(), } } fn config_from_draft(draft: &WoodenFishDraftSnapshot) -> WoodenFishCreatorConfigSnapshot { WoodenFishCreatorConfigSnapshot { work_title: draft.work_title.clone(), work_description: draft.work_description.clone(), theme_tags: draft.theme_tags.clone(), hit_object_prompt: draft.hit_object_prompt.clone(), hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(), hit_sound_prompt: draft.hit_sound_prompt.clone(), floating_words: normalize_floating_words(&draft.floating_words), } } fn build_wooden_fish_public_work_code(profile_id: &str) -> String { let normalized = profile_id .chars() .filter(|character| character.is_ascii_alphanumeric()) .flat_map(|character| character.to_uppercase()) .collect::(); let fallback = if normalized.is_empty() { "00000000".to_string() } else { normalized }; let suffix = if fallback.len() > 8 { fallback[fallback.len() - 8..].to_string() } else { format!("{fallback:0>8}") }; format!("WF-{suffix}") } fn run_status_as_str(status: WoodenFishRunStatus) -> &'static str { match status { WoodenFishRunStatus::Playing => "playing", WoodenFishRunStatus::Finished => "finished", } } fn require_non_empty(value: &str, label: &str) -> Result<(), String> { if value.trim().is_empty() { Err(format!("{label} 不能为空")) } else { Ok(()) } } fn clean_optional(value: &str) -> Option { let value = value.trim(); if value.is_empty() { None } else { Some(value.to_string()) } } fn clean_string(value: &str, fallback: &str) -> String { clean_optional(value).unwrap_or_else(|| fallback.to_string()) } fn parse_config(value: &str) -> Result { parse_json(value) } fn parse_tags(value: &str) -> Result, String> { Ok(parse_json_or_default::>(value) .into_iter() .map(|tag| tag.trim().to_string()) .filter(|tag| !tag.is_empty()) .take(8) .collect()) } fn parse_json(value: &str) -> Result where T: DeserializeOwned, { serde_json::from_str(value).map_err(|error| error.to_string()) } fn parse_json_or_default(value: &str) -> T where T: DeserializeOwned + Default, { serde_json::from_str(value).unwrap_or_default() } fn to_json_string(value: &T) -> String where T: Serialize, { serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) } fn session_result( session: WoodenFishAgentSessionSnapshot, ) -> WoodenFishAgentSessionProcedureResult { WoodenFishAgentSessionProcedureResult { ok: true, session: Some(session), error_message: None, } } fn session_error(message: String) -> WoodenFishAgentSessionProcedureResult { WoodenFishAgentSessionProcedureResult { ok: false, session: None, error_message: Some(message), } } fn work_result(work: WoodenFishWorkSnapshot) -> WoodenFishWorkProcedureResult { WoodenFishWorkProcedureResult { ok: true, work: Some(work), error_message: None, } } fn work_error(message: String) -> WoodenFishWorkProcedureResult { WoodenFishWorkProcedureResult { ok: false, work: None, error_message: Some(message), } } fn run_result(run: WoodenFishRunSnapshot) -> WoodenFishRunProcedureResult { WoodenFishRunProcedureResult { ok: true, run: Some(run), error_message: None, } } fn run_error(message: String) -> WoodenFishRunProcedureResult { WoodenFishRunProcedureResult { ok: false, run: None, error_message: Some(message), } } fn clone_session(row: &WoodenFishAgentSessionRow) -> WoodenFishAgentSessionRow { WoodenFishAgentSessionRow { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), current_turn: row.current_turn, progress_percent: row.progress_percent, stage: row.stage.clone(), config_json: row.config_json.clone(), draft_json: row.draft_json.clone(), published_profile_id: row.published_profile_id.clone(), created_at: row.created_at, updated_at: row.updated_at, } } fn clone_work(row: &WoodenFishWorkProfileRow) -> WoodenFishWorkProfileRow { WoodenFishWorkProfileRow { profile_id: row.profile_id.clone(), work_id: row.work_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), work_title: row.work_title.clone(), work_description: row.work_description.clone(), theme_tags_json: row.theme_tags_json.clone(), hit_object_prompt: row.hit_object_prompt.clone(), hit_object_reference_image_src: row.hit_object_reference_image_src.clone(), hit_sound_prompt: row.hit_sound_prompt.clone(), hit_object_asset_json: row.hit_object_asset_json.clone(), background_asset_json: row.background_asset_json.clone(), hit_sound_asset_json: row.hit_sound_asset_json.clone(), floating_words_json: row.floating_words_json.clone(), cover_image_src: row.cover_image_src.clone(), generation_status: row.generation_status.clone(), publication_status: row.publication_status.clone(), play_count: row.play_count, updated_at: row.updated_at, published_at: row.published_at, } } fn clone_run(row: &WoodenFishRuntimeRunRow) -> WoodenFishRuntimeRunRow { WoodenFishRuntimeRunRow { run_id: row.run_id.clone(), owner_user_id: row.owner_user_id.clone(), profile_id: row.profile_id.clone(), status: row.status.clone(), total_tap_count: row.total_tap_count, word_counters_json: row.word_counters_json.clone(), started_at_ms: row.started_at_ms, updated_at_ms: row.updated_at_ms, finished_at_ms: row.finished_at_ms, snapshot_json: row.snapshot_json.clone(), created_at: row.created_at, updated_at: row.updated_at, } }