diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index c9099484..642b66cf 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -159,7 +159,7 @@ npm run check:server-rs-ddd ## 外部服务与资产 - LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。 -- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。 +- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 @@ -168,6 +168,7 @@ npm run check:server-rs-ddd - 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 - OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。 - 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 +- 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。 ## SpacetimeDB 表目录 diff --git a/server-rs/crates/api-server/src/big_fish/formal_assets.rs b/server-rs/crates/api-server/src/big_fish/formal_assets.rs index f11beed2..e3276611 100644 --- a/server-rs/crates/api-server/src/big_fish/formal_assets.rs +++ b/server-rs/crates/api-server/src/big_fish/formal_assets.rs @@ -1,4 +1,5 @@ use super::*; +use crate::tracking::record_external_generation_run_after_success; struct BigFishDashScopeSettings { base_url: String, @@ -39,52 +40,99 @@ pub(super) async fn generate_big_fish_formal_asset( motion_key: Option<&str>, generated_at_micros: i64, ) -> Result { - let session = state - .spacetime_client() - .get_big_fish_session(session_id.to_string(), owner_user_id.to_string()) - .await - .map_err(map_big_fish_client_error)?; - let draft = session.draft.as_ref().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "big-fish", - "message": "玩法草稿尚未编译,不能生成正式图片。", - })) - })?; - let context = build_big_fish_formal_asset_context( - &session, - draft, - asset_kind, - level, - motion_key, - generated_at_micros, - )?; - let settings = require_big_fish_dashscope_settings(state)?; - let http_client = build_big_fish_dashscope_http_client(&settings)?; - let generated = create_big_fish_text_to_image_generation( - &http_client, - &settings, - context.prompt.as_str(), - context.negative_prompt.as_str(), - context.size.as_str(), - ) - .await?; - let downloaded = download_big_fish_remote_image( - &http_client, - generated.image_url.as_str(), - "下载 Big Fish 正式图片失败", - context.apply_transparent_background_post_process, - ) - .await?; + let started_at_micros = current_utc_micros(); + let request_payload = json!({ + "assetKind": asset_kind, + "level": level, + "motionKey": motion_key, + "sessionId": session_id, + "ownerUserId": owner_user_id, + }); + let outcome = async { + let session = state + .spacetime_client() + .get_big_fish_session(session_id.to_string(), owner_user_id.to_string()) + .await + .map_err(map_big_fish_client_error)?; + let draft = session.draft.as_ref().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "big-fish", + "message": "玩法草稿尚未编译,不能生成正式图片。", + })) + })?; + let context = build_big_fish_formal_asset_context( + &session, + draft, + asset_kind, + level, + motion_key, + generated_at_micros, + )?; + let settings = require_big_fish_dashscope_settings(state)?; + let http_client = build_big_fish_dashscope_http_client(&settings)?; + let generated = create_big_fish_text_to_image_generation( + &http_client, + &settings, + context.prompt.as_str(), + context.negative_prompt.as_str(), + context.size.as_str(), + ) + .await?; + let downloaded = download_big_fish_remote_image( + &http_client, + generated.image_url.as_str(), + "下载 Big Fish 正式图片失败", + context.apply_transparent_background_post_process, + ) + .await?; - persist_big_fish_formal_asset( - state, - owner_user_id, - &context, - generated, - downloaded, - generated_at_micros, - ) - .await + persist_big_fish_formal_asset( + state, + owner_user_id, + &context, + generated, + downloaded, + generated_at_micros, + ) + .await + } + .await; + match outcome { + Ok(value) => { + record_external_generation_run_after_success( + state, + "dashscope", + "big_fish_text_to_image", + "大鱼正式图片生成", + request_payload, + started_at_micros, + true, + None, + None, + Some(json!({ + "legacyPublicPath": value.clone(), + })), + ) + .await; + Ok(value) + } + Err(error) => { + record_external_generation_run_after_success( + state, + "dashscope", + "big_fish_text_to_image", + "大鱼正式图片生成", + request_payload, + started_at_micros, + false, + Some(error.to_string()), + None, + None, + ) + .await; + Err(error) + } + } } fn build_big_fish_formal_asset_context( @@ -626,6 +674,10 @@ fn map_big_fish_asset_binding_prepare_error(error: AssetObjectFieldError) -> App })) } +fn current_utc_micros() -> i64 { + (time::OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64 +} + fn map_big_fish_asset_spacetime_error(error: SpacetimeClientError) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 1c191fb2..c4e2a0f0 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -8,6 +8,7 @@ use platform_image::{ vector_engine_images_generation_url, }; use serde_json::{Value, json}; +use time::OffsetDateTime; use crate::{ external_api_audit::{ @@ -16,6 +17,7 @@ use crate::{ }, http_error::AppError, state::AppState, + tracking::record_external_generation_run_after_success, }; pub(crate) use platform_image::GPT_IMAGE_2_MODEL; @@ -105,6 +107,14 @@ pub(crate) async fn create_openai_image_generation( reference_images: &[String], failure_context: &str, ) -> Result { + let started_at_micros = current_utc_micros(); + let request_payload = json!({ + "size": size, + "candidateCount": candidate_count, + "promptChars": prompt.chars().count(), + "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), + "referenceImageCount": reference_images.len(), + }); let result = create_vector_engine_image_generation( http_client, &settings.provider_settings(), @@ -116,7 +126,15 @@ pub(crate) async fn create_openai_image_generation( failure_context, ) .await; - map_platform_image_result(settings, result).await + map_platform_image_result( + settings, + result, + "image_generation", + failure_context, + request_payload, + started_at_micros, + ) + .await } pub(crate) async fn create_openai_image_edit( @@ -128,6 +146,13 @@ pub(crate) async fn create_openai_image_edit( reference_image: &OpenAiReferenceImage, failure_context: &str, ) -> Result { + let started_at_micros = current_utc_micros(); + let request_payload = json!({ + "size": size, + "promptChars": prompt.chars().count(), + "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), + "referenceImageCount": 1, + }); let result = create_vector_engine_image_edit( http_client, &settings.provider_settings(), @@ -138,7 +163,15 @@ pub(crate) async fn create_openai_image_edit( failure_context, ) .await; - map_platform_image_result(settings, result).await + map_platform_image_result( + settings, + result, + "image_edit", + failure_context, + request_payload, + started_at_micros, + ) + .await } pub(crate) async fn create_openai_image_edit_with_references( @@ -151,6 +184,14 @@ pub(crate) async fn create_openai_image_edit_with_references( reference_images: &[OpenAiReferenceImage], failure_context: &str, ) -> Result { + let started_at_micros = current_utc_micros(); + let request_payload = json!({ + "size": size, + "candidateCount": candidate_count, + "promptChars": prompt.chars().count(), + "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), + "referenceImageCount": reference_images.len(), + }); let result = create_vector_engine_image_edit_with_references( http_client, &settings.provider_settings(), @@ -162,7 +203,15 @@ pub(crate) async fn create_openai_image_edit_with_references( failure_context, ) .await; - map_platform_image_result(settings, result).await + map_platform_image_result( + settings, + result, + "image_edit_with_references", + failure_context, + request_payload, + started_at_micros, + ) + .await } pub(crate) async fn download_remote_image( @@ -200,19 +249,57 @@ impl OpenAiImageSettings { } } -async fn map_platform_image_result( +async fn map_platform_image_result( settings: &OpenAiImageSettings, - result: Result, -) -> Result { + result: Result, + operation: &'static str, + failure_context: &str, + request_payload: Value, + started_at_micros: i64, +) -> Result { match result { - Ok(value) => Ok(value), + Ok(value) => { + if let Some(state) = settings.external_api_audit_state.as_ref() { + record_external_generation_run_after_success( + state, + VECTOR_ENGINE_PROVIDER, + operation, + failure_context, + request_payload, + started_at_micros, + true, + None, + Some(value.task_id.clone()), + Some(json!({ + "imageCount": value.images.len(), + "actualPromptChars": value.actual_prompt.as_ref().map(|prompt| prompt.chars().count()), + })), + ) + .await; + } + Ok(value) + } Err(error) => { + if let Some(state) = settings.external_api_audit_state.as_ref() { + record_external_generation_run_after_success( + state, + VECTOR_ENGINE_PROVIDER, + operation, + failure_context, + request_payload, + started_at_micros, + false, + Some(error.message().to_string()), + None, + None, + ) + .await; + } record_openai_image_failure_if_configured(settings, &error).await; Err(map_platform_image_error(error)) } } } - pub(crate) async fn record_openai_image_failure_if_configured( settings: &OpenAiImageSettings, error: &PlatformImageError, @@ -457,3 +544,7 @@ mod tests { ); } } + +fn current_utc_micros() -> i64 { + (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64 +} diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 56cc3b73..b4fe7b41 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -62,8 +62,8 @@ use spacetime_client::{ PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, - PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, - PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, + PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, + PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError, diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index 82670c35..f878902a 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -53,6 +53,55 @@ struct RouteTrackingSpec { scope_id: &'static str, } +pub async fn record_external_generation_run_after_success( + state: &AppState, + provider: &str, + operation: &str, + request_label: &str, + request_payload: Value, + started_at_micros: i64, + success: bool, + failure_reason: Option, + provider_request_id: Option, + result_payload: Option, +) { + let completed_at_micros = current_utc_micros(); + let duration_ms = completed_at_micros.saturating_sub(started_at_micros).max(0) / 1_000; + let mut draft = TrackingEventDraft::new("external_generation_run", "external-generation"); + draft.scope_kind = RuntimeTrackingScopeKind::Module; + draft.scope_id = provider.to_string(); + draft.metadata = json!({ + "runId": format!("external-generation-{}", Uuid::new_v4()), + "provider": provider, + "operation": operation, + "requestLabel": request_label.trim(), + "requestPayload": request_payload, + "status": if success { "succeeded" } else { "failed" }, + "success": success, + "failureReason": failure_reason, + "providerRequestId": provider_request_id, + "resultPayload": result_payload, + "startedAtMicros": started_at_micros, + "completedAtMicros": completed_at_micros, + "durationMs": duration_ms, + }); + + record_tracking_event_after_success(state, &external_generation_request_context(), draft).await; +} + +fn current_utc_micros() -> i64 { + (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64 +} + +fn external_generation_request_context() -> RequestContext { + RequestContext::new( + format!("external-generation-{}", Uuid::new_v4()), + "external generation run".to_string(), + std::time::Duration::ZERO, + false, + ) +} + pub async fn record_route_tracking_event_after_success( state: &AppState, request_context: &RequestContext, diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs index a48dff16..7aca5791 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -1,9 +1,12 @@ +use serde_json::json; use shared_contracts::creation_audio; -use crate::{http_error::AppError, state::AppState}; +use crate::{ + http_error::AppError, state::AppState, tracking::record_external_generation_run_after_success, +}; use super::{ - clock::current_utc_iso_text, + clock::{current_utc_iso_text, current_utc_micros}, errors::{map_platform_audio_error, vector_engine_bad_gateway}, publish::wait_for_generated_audio_asset, tasks::{create_background_music_task_response, create_sound_effect_task_response}, @@ -18,45 +21,69 @@ pub(crate) async fn generate_sound_effect_asset_for_creation( seed: Option, target: GeneratedCreationAudioTarget, ) -> Result { + let started_at_micros = current_utc_micros(); let normalized_prompt = platform_audio::normalize_limited_text( &prompt, "prompt", platform_audio::VIDU_PROMPT_MAX_CHARS, ) .map_err(map_platform_audio_error)?; - let task = - create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed).await?; - let target = AudioAssetBindingTarget { - storage_scope: target.entity_kind.clone(), - entity_kind: target.entity_kind, - entity_id: target.entity_id, - slot: target.slot, - asset_kind: target.asset_kind, - profile_id: target.profile_id, - storage_prefix: target.storage_prefix, - }; - let generated = wait_for_generated_audio_asset( - state, - owner_user_id, - task.task_id.clone(), - AudioAssetSlot::SoundEffect, - target, - ) - .await?; - let audio_src = generated - .audio_src - .ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?; + let request_payload = json!({ + "kind": "sound_effect", + "promptChars": normalized_prompt.chars().count(), + "duration": duration, + "seed": seed, + "targetEntityKind": target.entity_kind, + "targetEntityId": target.entity_id, + "targetSlot": target.slot, + "targetAssetKind": target.asset_kind, + }); + let outcome = async { + let task = + create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed) + .await?; + let target = AudioAssetBindingTarget { + storage_scope: target.entity_kind.clone(), + entity_kind: target.entity_kind, + entity_id: target.entity_id, + slot: target.slot, + asset_kind: target.asset_kind, + profile_id: target.profile_id, + storage_prefix: target.storage_prefix, + }; + let generated = wait_for_generated_audio_asset( + state, + owner_user_id, + task.task_id.clone(), + AudioAssetSlot::SoundEffect, + target, + ) + .await?; + let audio_src = generated + .audio_src + .ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?; - Ok(creation_audio::CreationAudioAsset { - task_id: generated.task_id, - provider: generated.provider, - asset_object_id: generated.asset_object_id, - asset_kind: generated.asset_kind, - audio_src, - prompt: Some(normalized_prompt), - title: None, - updated_at: Some(current_utc_iso_text()), - }) + Ok::<_, AppError>(creation_audio::CreationAudioAsset { + task_id: generated.task_id, + provider: generated.provider, + asset_object_id: generated.asset_object_id, + asset_kind: generated.asset_kind, + audio_src, + prompt: Some(normalized_prompt), + title: None, + updated_at: Some(current_utc_iso_text()), + }) + } + .await; + record_creation_audio_generation_run( + state, + "sound_effect", + request_payload, + started_at_micros, + &outcome, + ) + .await; + outcome } pub(crate) async fn generate_background_music_asset_for_creation( @@ -68,6 +95,7 @@ pub(crate) async fn generate_background_music_asset_for_creation( model: Option, target: GeneratedCreationAudioTarget, ) -> Result { + let started_at_micros = current_utc_micros(); let normalized_prompt = platform_audio::normalize_limited_text_allow_empty( &prompt, "prompt", @@ -80,43 +108,111 @@ pub(crate) async fn generate_background_music_asset_for_creation( platform_audio::SUNO_TITLE_MAX_CHARS, ) .map_err(map_platform_audio_error)?; - let task = create_background_music_task_response( - state, - normalized_prompt.clone(), - normalized_title.clone(), - tags, - model, - ) - .await?; - let target = AudioAssetBindingTarget { - storage_scope: target.entity_kind.clone(), - entity_kind: target.entity_kind, - entity_id: target.entity_id, - slot: target.slot, - asset_kind: target.asset_kind, - profile_id: target.profile_id, - storage_prefix: target.storage_prefix, - }; - let generated = wait_for_generated_audio_asset( - state, - owner_user_id, - task.task_id.clone(), - AudioAssetSlot::BackgroundMusic, - target, - ) - .await?; - let audio_src = generated - .audio_src - .ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?; + let request_payload = json!({ + "kind": "background_music", + "promptChars": normalized_prompt.chars().count(), + "titleChars": normalized_title.chars().count(), + "hasTags": tags.as_ref().is_some_and(|value| !value.trim().is_empty()), + "model": model, + "targetEntityKind": target.entity_kind, + "targetEntityId": target.entity_id, + "targetSlot": target.slot, + "targetAssetKind": target.asset_kind, + }); + let outcome = async { + let task = create_background_music_task_response( + state, + normalized_prompt.clone(), + normalized_title.clone(), + tags, + model, + ) + .await?; + let target = AudioAssetBindingTarget { + storage_scope: target.entity_kind.clone(), + entity_kind: target.entity_kind, + entity_id: target.entity_id, + slot: target.slot, + asset_kind: target.asset_kind, + profile_id: target.profile_id, + storage_prefix: target.storage_prefix, + }; + let generated = wait_for_generated_audio_asset( + state, + owner_user_id, + task.task_id.clone(), + AudioAssetSlot::BackgroundMusic, + target, + ) + .await?; + let audio_src = generated + .audio_src + .ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?; - Ok(creation_audio::CreationAudioAsset { - task_id: generated.task_id, - provider: generated.provider, - asset_object_id: generated.asset_object_id, - asset_kind: generated.asset_kind, - audio_src, - prompt: Some(normalized_prompt), - title: Some(normalized_title), - updated_at: Some(current_utc_iso_text()), - }) + Ok::<_, AppError>(creation_audio::CreationAudioAsset { + task_id: generated.task_id, + provider: generated.provider, + asset_object_id: generated.asset_object_id, + asset_kind: generated.asset_kind, + audio_src, + prompt: Some(normalized_prompt), + title: Some(normalized_title), + updated_at: Some(current_utc_iso_text()), + }) + } + .await; + record_creation_audio_generation_run( + state, + "background_music", + request_payload, + started_at_micros, + &outcome, + ) + .await; + outcome +} + +async fn record_creation_audio_generation_run( + state: &AppState, + operation: &'static str, + request_payload: serde_json::Value, + started_at_micros: i64, + outcome: &Result, +) { + match outcome { + Ok(asset) => { + record_external_generation_run_after_success( + state, + asset.provider.as_str(), + operation, + "创作音频生成", + request_payload, + started_at_micros, + true, + None, + Some(asset.task_id.clone()), + Some(json!({ + "assetObjectId": asset.asset_object_id, + "assetKind": asset.asset_kind, + "hasAudioSrc": !asset.audio_src.trim().is_empty(), + })), + ) + .await; + } + Err(error) => { + record_external_generation_run_after_success( + state, + "vector-engine-audio", + operation, + "创作音频生成", + request_payload, + started_at_micros, + false, + Some(error.to_string()), + None, + None, + ) + .await; + } + } } diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index 7763ea0e..580555b5 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -20,8 +20,8 @@ use shared_contracts::wooden_fish::{ WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse, WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse, WoodenFishSessionResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest, - WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkspaceCreateRequest, - WoodenFishWorksResponse, + WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorksResponse, + WoodenFishWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index 0c0db1a1..df8b1c4f 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -1811,7 +1811,10 @@ pub fn select_runtime_next_profile<'a>( prefer_similar_work: bool, ) -> Option<&'a PuzzleWorkProfile> { if prefer_similar_work { - similar_work_profiles.first().copied().or(same_work_next_profile) + similar_work_profiles + .first() + .copied() + .or(same_work_next_profile) } else { same_work_next_profile.or_else(|| similar_work_profiles.first().copied()) } @@ -3281,7 +3284,10 @@ mod tests { assert_eq!(failed.generation_status, "failed"); assert_eq!(failed.levels[0].generation_status, "failed"); assert_eq!(failed.levels[1].generation_status, "ready"); - assert_eq!(failed.levels[1].cover_image_src.as_deref(), Some("/ready.png")); + assert_eq!( + failed.levels[1].cover_image_src.as_deref(), + Some("/ready.png") + ); } #[test] @@ -3338,12 +3344,8 @@ mod tests { let same_work = build_published_profile("same", "owner-a", vec!["奇幻"]); let similar_work = build_published_profile("similar", "owner-b", vec!["奇幻"]); let similar_work_profiles = [&similar_work]; - let selected = select_runtime_next_profile( - Some(&same_work), - &similar_work_profiles, - true, - ) - .expect("should select similar work first"); + let selected = select_runtime_next_profile(Some(&same_work), &similar_work_profiles, true) + .expect("should select similar work first"); assert_eq!(selected.profile_id, "similar"); } diff --git a/server-rs/crates/platform-hyper3d/src/response/tests.rs b/server-rs/crates/platform-hyper3d/src/response/tests.rs index e93b1b94..e4016dfd 100644 --- a/server-rs/crates/platform-hyper3d/src/response/tests.rs +++ b/server-rs/crates/platform-hyper3d/src/response/tests.rs @@ -1,11 +1,11 @@ use serde_json::json; use shared_contracts::hyper3d as contract; +use super::status::normalize_task_status; use super::{ build_submit_response, extract_download_files, extract_job_statuses, resolve_hyper3d_overall_status, }; -use super::status::normalize_task_status; #[test] fn extracts_submit_response_from_nested_payload() { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f8a5c00a..f6bb3217 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -53,15 +53,15 @@ pub use mapper::{ PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, - PuzzleFormDraftRecord, - PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, - PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, - PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, - PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, - PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput, - PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, + PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, + PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, + PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, + PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, + PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, + PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 1dde49d0..3d6fd06a 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -102,15 +102,15 @@ pub use self::puzzle::{ PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, - PuzzleFormDraftRecord, - PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, - PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, - PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, - PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, - PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput, - PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, + PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, + PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, + PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, + PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, + PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, + PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, }; diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index 0d730c0f..25ec5ad9 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -183,15 +183,12 @@ impl SpacetimeClient { move |connection, sender| { connection .procedures() - .mark_puzzle_draft_generation_failed_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_puzzle_agent_session_procedure_result); - send_once(&sender, mapped); - }, - ); + .mark_puzzle_draft_generation_failed_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }); }, ) .await diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 567a2998..80a73e2f 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -10,8 +10,8 @@ use module_puzzle::{ PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind, PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, - PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput, - PuzzleDraftCompileFailureInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, + PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput, + PuzzleFormDraftSaveInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, @@ -2193,7 +2193,7 @@ fn advance_puzzle_next_level_tx( &similar_work_profiles, input.prefer_similar_work, ) - .ok_or_else(|| "没有可用的下一关候选".to_string())?; + .ok_or_else(|| "没有可用的下一关候选".to_string())?; let mut next_run = if similar_work_next_profile.is_some() { module_puzzle::advance_to_new_work_first_level_at( ¤t_run,