use super::*; pub async fn create_match3d_agent_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; let config = build_config_from_create_request(&payload); let seed_text = build_seed_text(&payload, &config); let welcome_message_text = MATCH3D_QUESTION_THEME.to_string(); let session = state .spacetime_client() .create_match3d_agent_session(Match3DAgentSessionCreateRecordInput { session_id: build_prefixed_uuid_id(MATCH3D_SESSION_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), seed_text, welcome_message_id: build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX), welcome_message_text, config_json: serialize_match3d_config(&config), created_at_micros: current_utc_micros(), }) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DAgentSessionResponse { session: load_match3d_agent_session_response_with_persisted_assets( &state, authenticated.claims().user_id(), session, ) .await, }, )) } pub async fn get_match3d_agent_session( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, MATCH3D_AGENT_PROVIDER, &session_id, "sessionId", )?; let session = state .spacetime_client() .get_match3d_agent_session(session_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DAgentSessionResponse { session: load_match3d_agent_session_response_with_persisted_assets( &state, authenticated.claims().user_id(), session, ) .await, }, )) } pub async fn submit_match3d_agent_message( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; let session = submit_and_finalize_match3d_message( &state, &request_context, authenticated.claims().user_id(), session_id, payload, ) .await?; Ok(json_success_body( Some(&request_context), Match3DAgentSessionResponse { session: load_match3d_agent_session_response_with_persisted_assets( &state, authenticated.claims().user_id(), session, ) .await, }, )) } pub async fn stream_match3d_agent_message( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_AGENT_PROVIDER, &session_id, "sessionId", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let request_context_for_stream = request_context.clone(); let stream = async_stream::stream! { let result = submit_and_finalize_match3d_message( &state, &request_context_for_stream, owner_user_id.as_str(), session_id, payload, ) .await; match result { Ok(session) => { let session_response = load_match3d_agent_session_response_with_persisted_assets( &state, owner_user_id.as_str(), session, ) .await; if let Some(reply) = session_response.last_assistant_reply.clone() { yield Ok::(match3d_sse_json_event_or_error( "reply_delta", json!({ "text": reply }), )); } yield Ok::(match3d_sse_json_event_or_error( "session", json!({ "session": session_response }), )); yield Ok::(match3d_sse_json_event_or_error( "done", json!({ "ok": true }), )); } Err(response) => { yield Ok::(match3d_sse_json_event_or_error( "error", json!({ "message": response.status().to_string() }), )); } } }; Ok(Sse::new(stream).into_response()) } pub async fn execute_match3d_agent_action( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_AGENT_PROVIDER, &session_id, "sessionId", )?; if payload.action.trim() != "match3d_compile_draft" { return Err(match3d_bad_request( &request_context, MATCH3D_AGENT_PROVIDER, "unknown match3d action", )); } let (session, generated_item_assets) = compile_match3d_draft_for_session( &state, &request_context, &authenticated, session_id, payload.game_name, payload.summary, payload.tags, payload.cover_image_src, payload.generate_click_sound, ) .await?; Ok(json_success_body( Some(&request_context), Match3DAgentActionResponse { session: map_match3d_agent_session_response_with_assets( session, &generated_item_assets, ), }, )) } pub async fn compile_match3d_agent_draft( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let payload = payload .map(|Json(payload)| payload) .unwrap_or(CompileMatch3DDraftRequest { game_name: None, summary: None, tags: None, cover_image_src: None, generate_click_sound: None, }); ensure_non_empty( &request_context, MATCH3D_AGENT_PROVIDER, &session_id, "sessionId", )?; let (session, generated_item_assets) = compile_match3d_draft_for_session( &state, &request_context, &authenticated, session_id, payload.game_name, payload.summary, payload.tags, payload.cover_image_src, payload.generate_click_sound, ) .await?; Ok(json_success_body( Some(&request_context), Match3DAgentActionResponse { session: map_match3d_agent_session_response_with_assets( session, &generated_item_assets, ), }, )) } pub async fn get_match3d_works( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let items = state .spacetime_client() .list_match3d_works(authenticated.claims().user_id().to_string()) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DWorksResponse { items: items .into_iter() .map(map_match3d_work_summary_response) .collect(), }, )) } pub async fn list_match3d_gallery( State(state): State, Extension(request_context): Extension, ) -> Result, Response> { let items = state .spacetime_client() .list_match3d_gallery() .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DWorksResponse { items: items .into_iter() .map(map_match3d_work_summary_response) .collect(), }, )) } pub async fn get_match3d_work_detail( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .get_match3d_work_detail(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DWorkDetailResponse { item: map_match3d_work_profile_response(item), }, )) } pub async fn put_match3d_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let existing = state .spacetime_client() .get_match3d_work_detail( profile_id.clone(), authenticated.claims().user_id().to_string(), ) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; let theme_text = payload .theme_text .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or(existing.theme_text); let item = state .spacetime_client() .update_match3d_work(Match3DWorkUpdateRecordInput { profile_id, owner_user_id: authenticated.claims().user_id().to_string(), game_name: payload.game_name, theme_text, summary_text: payload.summary, tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(), cover_image_src: payload.cover_image_src.unwrap_or_default(), cover_asset_id: String::new(), clear_count: payload.clear_count, difficulty: payload.difficulty, updated_at_micros: current_utc_micros(), }) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DWorkMutationResponse { item: map_match3d_work_profile_response(item), }, )) } pub async fn put_match3d_audio_assets( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let existing = state .spacetime_client() .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; let session_id = existing.source_session_id.clone().ok_or_else(|| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "message": "抓大鹅作品缺少来源 session,无法写回音频素材", })), ) })?; let assets = payload .generated_item_assets .into_iter() .map(Match3DGeneratedItemAsset::from) .collect::>(); let session = upsert_match3d_draft_snapshot( &state, &request_context, &authenticated, session_id, owner_user_id.clone(), profile_id.clone(), Some(existing.game_name), Some(existing.summary), Some(serde_json::to_string(&existing.tags).unwrap_or_default()), existing.cover_image_src, None, serialize_match3d_generated_item_assets(&assets), ) .await?; let item = state .spacetime_client() .get_match3d_work_detail(profile_id, owner_user_id) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; let _ = session; Ok(json_success_body( Some(&request_context), Match3DWorkMutationResponse { item: map_match3d_work_profile_response(item), }, )) } pub async fn persist_match3d_generated_model( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &payload.item_id, "itemId", )?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &payload.item_name, "itemName", )?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &payload.source_url, "sourceUrl", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let existing = state .spacetime_client() .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; let session_id = existing.source_session_id.clone().ok_or_else(|| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "message": "抓大鹅作品缺少来源 session,无法保存历史模型", })), ) })?; let mut assets = parse_match3d_generated_item_assets(existing.generated_item_assets_json.as_deref()) .into_iter() .map(Match3DGeneratedItemAsset::from) .collect::>(); let current_asset = assets .iter() .find(|asset| asset.item_id == payload.item_id) .cloned(); let item_name = normalize_match3d_item_name(payload.item_name.as_str()); let item_name = if item_name.is_empty() { current_asset .as_ref() .map(|asset| asset.item_name.clone()) .unwrap_or_else(|| payload.item_name.trim().to_string()) } else { item_name }; let model_file = hyper3d_contract::Hyper3dDownloadFilePayload { name: normalize_optional_text(payload.file_name.as_deref()) .unwrap_or_else(|| "model.glb".to_string()), url: payload.source_url.trim().to_string(), }; let downloaded_model = download_match3d_legacy_model(&model_file) .await .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; let task_uuid = normalize_optional_text(payload.task_uuid.as_deref()); let item_slug = build_match3d_item_slug(payload.item_id.as_str(), item_name.as_str()); let generated_at_micros = current_utc_micros(); let uploaded_model = persist_match3d_generated_bytes( &state, owner_user_id.as_str(), session_id.as_str(), profile_id.as_str(), &[ "items", item_slug.as_str(), "model", task_uuid.as_deref().unwrap_or("manual"), ], downloaded_model.file_name.as_str(), downloaded_model.content_type.as_str(), downloaded_model.bytes, "match3d_item_model", task_uuid.as_deref(), generated_at_micros, ) .await .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; let next_asset = Match3DGeneratedItemAsset { item_id: payload.item_id, item_name, item_size: current_asset .as_ref() .and_then(|asset| asset.item_size.clone()) .or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())), image_src: current_asset .as_ref() .and_then(|asset| asset.image_src.clone()), image_object_key: current_asset .as_ref() .and_then(|asset| asset.image_object_key.clone()), image_views: current_asset .as_ref() .map(|asset| asset.image_views.clone()) .unwrap_or_default(), model_src: Some(uploaded_model.src), model_object_key: Some(uploaded_model.object_key), model_file_name: Some(downloaded_model.file_name), task_uuid, subscription_key: normalize_optional_text(payload.subscription_key.as_deref()).or_else( || { current_asset .as_ref() .and_then(|asset| asset.subscription_key.clone()) }, ), sound_prompt: current_asset .as_ref() .and_then(|asset| asset.sound_prompt.clone()), background_music_title: current_asset .as_ref() .and_then(|asset| asset.background_music_title.clone()), background_music_style: current_asset .as_ref() .and_then(|asset| asset.background_music_style.clone()), background_music_prompt: current_asset .as_ref() .and_then(|asset| asset.background_music_prompt.clone()), background_music: current_asset .as_ref() .and_then(|asset| asset.background_music.clone()), click_sound: current_asset .as_ref() .and_then(|asset| asset.click_sound.clone()), background_asset: current_asset .as_ref() .and_then(|asset| asset.background_asset.clone()), status: "model_ready".to_string(), error: None, }; upsert_match3d_generated_item_asset(&mut assets, next_asset.clone()); persist_match3d_generated_item_assets_snapshot( &state, &request_context, &authenticated, session_id.as_str(), owner_user_id.as_str(), profile_id.as_str(), &assets, ) .await?; Ok(json_success_body( Some(&request_context), PersistMatch3DGeneratedModelResponse { asset: map_match3d_generated_item_asset_for_work(Match3DGeneratedItemAssetJson::from( next_asset, )), }, )) } pub async fn generate_match3d_cover_image( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let prompt = normalize_match3d_cover_prompt(payload.prompt.as_str()); ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; let context = load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) .await?; let generated_cover = generate_match3d_cover_image_asset( &state, &request_context, &context.owner_user_id, context.session_id.as_str(), profile_id.as_str(), &context.config, prompt.as_str(), payload.uploaded_image_src, collect_match3d_cover_reference_image_sources( payload.reference_image_src, payload.reference_image_srcs, ), ) .await .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; let item = update_match3d_work_cover_only( &state, &request_context, context.owner_user_id.as_str(), context.profile, generated_cover.src.as_str(), ) .await?; Ok(json_success_body( Some(&request_context), GenerateMatch3DCoverImageResponse { item: map_match3d_work_profile_response(item), cover_image_src: generated_cover.src, cover_image_object_key: generated_cover.object_key, prompt, }, )) } pub async fn generate_match3d_background_image_for_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let prompt = normalize_match3d_background_prompt(payload.prompt.as_str()); ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str()); let context = load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) .await?; let Match3DWorkAssetContext { owner_user_id, session_id, profile, config, assets, } = context; let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, prompt_fingerprint); let (generated_background, generated_assets) = execute_billable_asset_operation_with_cost( &state, owner_user_id.as_str(), "match3d_ui_background_image", billing_asset_id.as_str(), MATCH3D_BACKGROUND_IMAGE_POINTS_COST, async { let generated_background = generate_match3d_background_image( &state, &request_context, owner_user_id.as_str(), session_id.as_str(), profile_id.as_str(), &config, prompt.as_str(), ) .await?; let mut assets = assets; attach_match3d_background_asset_to_assets(&mut assets, generated_background.clone()); let save_result = persist_match3d_generated_item_assets_snapshot( &state, &request_context, &authenticated, session_id.as_str(), owner_user_id.as_str(), profile_id.as_str(), &assets, ) .await; if let Err(response) = save_result { tracing::warn!( provider = MATCH3D_WORKS_PROVIDER, profile_id, owner_user_id = %owner_user_id, status = %response.status(), "抓大鹅 UI 背景图已生成但 SpacetimeDB 草稿写回不可用,降级返回本次生成资产" ); } Ok((generated_background, assets)) }, ) .await .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; let item = state .spacetime_client() .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) .await .map(|item| map_match3d_work_profile_response(item)) .unwrap_or_else(|error| { tracing::warn!( provider = MATCH3D_WORKS_PROVIDER, profile_id, owner_user_id = %owner_user_id, error = %error, "抓大鹅 UI 背景图生成后读取作品详情失败,降级使用写回前快照" ); map_match3d_work_profile_response(build_match3d_work_profile_record_with_assets( profile, &generated_assets, )) }); let background_image_src = generated_background.image_src.clone().unwrap_or_default(); let background_image_object_key = generated_background .image_object_key .clone() .unwrap_or_default(); Ok(json_success_body( Some(&request_context), GenerateMatch3DBackgroundImageResponse { item, background_image_src, background_image_object_key, generated_background_asset: map_match3d_background_asset_for_work(generated_background), prompt, }, )) } pub async fn generate_match3d_container_image_for_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let prompt = normalize_match3d_background_prompt(payload.prompt.as_str()); ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str()); let context = load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) .await?; let Match3DWorkAssetContext { owner_user_id, session_id, profile, config, assets, } = context; let billing_asset_id = format!( "{}:{}:{}:container", session_id, profile_id, prompt_fingerprint ); let (generated_background, generated_assets) = execute_billable_asset_operation_with_cost( &state, owner_user_id.as_str(), "match3d_ui_container_image", billing_asset_id.as_str(), MATCH3D_BACKGROUND_IMAGE_POINTS_COST, async { let generated_container = generate_match3d_container_image( &state, &request_context, owner_user_id.as_str(), session_id.as_str(), profile_id.as_str(), &config, prompt.as_str(), ) .await?; let mut assets = assets; let generated_background = merge_match3d_container_image_into_background_asset(&assets, generated_container); attach_match3d_background_asset_to_assets(&mut assets, generated_background.clone()); let save_result = persist_match3d_generated_item_assets_snapshot( &state, &request_context, &authenticated, session_id.as_str(), owner_user_id.as_str(), profile_id.as_str(), &assets, ) .await; if let Err(response) = save_result { tracing::warn!( provider = MATCH3D_WORKS_PROVIDER, profile_id, owner_user_id = %owner_user_id, status = %response.status(), "抓大鹅容器形象已生成但 SpacetimeDB 草稿写回不可用,降级返回本次生成资产" ); } Ok((generated_background, assets)) }, ) .await .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; let item = state .spacetime_client() .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) .await .map(|item| map_match3d_work_profile_response(item)) .unwrap_or_else(|error| { tracing::warn!( provider = MATCH3D_WORKS_PROVIDER, profile_id, owner_user_id = %owner_user_id, error = %error, "抓大鹅容器形象生成后读取作品详情失败,降级使用写回前快照" ); map_match3d_work_profile_response(build_match3d_work_profile_record_with_assets( profile, &generated_assets, )) }); let container_image_src = generated_background .container_image_src .clone() .unwrap_or_default(); let container_image_object_key = generated_background .container_image_object_key .clone() .unwrap_or_default(); Ok(json_success_body( Some(&request_context), GenerateMatch3DContainerImageResponse { item, container_image_src, container_image_object_key, generated_background_asset: map_match3d_background_asset_for_work(generated_background), prompt, }, )) } pub async fn generate_match3d_item_assets_for_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let item_names = normalize_match3d_batch_item_names(payload.item_names); if item_names.is_empty() { return Err(match3d_bad_request( &request_context, MATCH3D_WORKS_PROVIDER, "请填写至少一个物品名称", )); } let generation_mode = normalize_match3d_item_assets_generation_mode(payload.mode.as_deref()); let context = load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) .await?; let Match3DWorkAssetContext { owner_user_id, session_id, profile, config, assets, } = context; let generation_plan = build_match3d_item_assets_generation_plan(generation_mode, item_names, &assets); if generation_plan.billed_item_count() == 0 { return Ok(json_success_body( Some(&request_context), GenerateMatch3DItemAssetsResponse { item: map_match3d_work_profile_response(profile), generated_item_assets: sort_match3d_generated_assets(assets) .into_iter() .map(Match3DGeneratedItemAssetJson::from) .map(map_match3d_generated_item_asset_for_work) .collect(), }, )); } let billed_item_count = generation_plan.billed_item_count(); let points_cost = calculate_match3d_item_assets_points_cost(billed_item_count); let billing_asset_id = format!( "{}:{}:{}:{}", session_id, profile_id, billed_item_count, build_match3d_prompt_fingerprint(generation_plan.billing_fingerprint_source().as_str()) ); let generated_assets = execute_billable_asset_operation_with_cost( &state, owner_user_id.as_str(), "match3d_item_assets", billing_asset_id.as_str(), points_cost, async { append_match3d_item_assets( &state, &request_context, &authenticated, owner_user_id.as_str(), session_id.as_str(), profile_id.as_str(), &config, generation_plan, assets, ) .await .map_err(|response| { AppError::from_status(response.status()).with_details(json!({ "provider": MATCH3D_WORKS_PROVIDER, "message": "抓大鹅批量新增物品素材失败", })) }) }, ) .await .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; let item = state .spacetime_client() .get_match3d_work_detail(profile_id, owner_user_id) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), GenerateMatch3DItemAssetsResponse { item: map_match3d_work_profile_response(item), generated_item_assets: generated_assets .into_iter() .map(Match3DGeneratedItemAssetJson::from) .map(map_match3d_generated_item_asset_for_work) .collect(), }, )) } pub async fn generate_match3d_work_tags( State(state): State, Extension(request_context): Extension, Extension(_authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; let tags = generate_match3d_work_tags_for_profile( &state, payload.game_name.as_str(), payload.theme_text.as_str(), payload.summary.as_deref(), ) .await; Ok(json_success_body( Some(&request_context), GenerateMatch3DWorkTagsResponse { tags }, )) } pub async fn publish_match3d_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .publish_match3d_work( profile_id, authenticated.claims().user_id().to_string(), current_utc_micros(), ) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DWorkMutationResponse { item: map_match3d_work_profile_response(item), }, )) } pub async fn delete_match3d_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, MATCH3D_WORKS_PROVIDER, &profile_id, "profileId", )?; let items = state .spacetime_client() .delete_match3d_work(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_WORKS_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DWorksResponse { items: items .into_iter() .map(map_match3d_work_summary_response) .collect(), }, )) } pub async fn start_match3d_run( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let maybe_payload = payload.ok().map(|Json(payload)| payload); let profile_id = maybe_payload .as_ref() .map(|payload| payload.profile_id.clone()) .filter(|value| !value.trim().is_empty()) .unwrap_or(profile_id); ensure_non_empty( &request_context, MATCH3D_RUNTIME_PROVIDER, &profile_id, "profileId", )?; let run = state .spacetime_client() .start_match3d_run(Match3DRunStartRecordInput { run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), profile_id: profile_id.clone(), started_at_ms: current_utc_ms(), item_type_count_override: maybe_payload .as_ref() .and_then(|payload| payload.item_type_count_override) .unwrap_or(0), }) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_RUNTIME_PROVIDER, map_match3d_client_error(error), ) })?; record_work_play_start_after_success( &state, &request_context, WorkPlayTrackingDraft::new( "match3d", profile_id.clone(), &authenticated, "/api/runtime/match3d/...", ) .profile_id(profile_id.clone()) .extra(json!({ "runId": run.run_id, })), ) .await; Ok(json_success_body( Some(&request_context), Match3DRunResponse { run: map_match3d_run_response(run), }, )) } pub async fn get_match3d_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .get_match3d_run(run_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_RUNTIME_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DRunResponse { run: map_match3d_run_response(run), }, )) } pub async fn click_match3d_item( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = match3d_json(payload, &request_context, MATCH3D_RUNTIME_PROVIDER)?; ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; ensure_non_empty( &request_context, MATCH3D_RUNTIME_PROVIDER, &payload.item_instance_id, "itemInstanceId", )?; ensure_non_empty( &request_context, MATCH3D_RUNTIME_PROVIDER, &payload.client_event_id, "clientEventId", )?; let confirmation = state .spacetime_client() .click_match3d_item(Match3DRunClickRecordInput { run_id: payload.run_id.unwrap_or(run_id), owner_user_id: authenticated.claims().user_id().to_string(), item_instance_id: payload.item_instance_id, client_snapshot_version: payload.client_snapshot_version.min(u32::MAX as u64) as u32, client_event_id: payload.client_event_id, clicked_at_ms: payload.clicked_at_ms.min(i64::MAX as u64) as i64, }) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_RUNTIME_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DClickResponse { confirmation: map_match3d_click_confirmation_response(confirmation), }, )) } pub async fn stop_match3d_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let _ = payload.ok(); ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .stop_match3d_run(Match3DRunStopRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), stopped_at_ms: current_utc_ms(), }) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_RUNTIME_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DRunResponse { run: map_match3d_run_response(run), }, )) } pub async fn restart_match3d_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .restart_match3d_run(Match3DRunRestartRecordInput { source_run_id: run_id, next_run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), restarted_at_ms: current_utc_ms(), }) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_RUNTIME_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DRunResponse { run: map_match3d_run_response(run), }, )) } pub async fn finish_match3d_time_up( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .finish_match3d_time_up(Match3DRunTimeUpRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), finished_at_ms: current_utc_ms(), }) .await .map_err(|error| { match3d_error_response( &request_context, MATCH3D_RUNTIME_PROVIDER, map_match3d_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), Match3DRunResponse { run: map_match3d_run_response(run), }, )) }