Files
Genarrative/server-rs/crates/api-server/src/match3d/handlers.rs
2026-05-28 15:42:46 +08:00

1410 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
pub async fn create_match3d_agent_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CreateMatch3DAgentSessionRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<SendMatch3DAgentMessageRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<SendMatch3DAgentMessageRequest>, JsonRejection>,
) -> Result<Response, Response> {
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::<Event, Infallible>(match3d_sse_json_event_or_error(
"reply_delta",
json!({ "text": reply }),
));
}
yield Ok::<Event, Infallible>(match3d_sse_json_event_or_error(
"session",
json!({ "session": session_response }),
));
yield Ok::<Event, Infallible>(match3d_sse_json_event_or_error(
"done",
json!({ "ok": true }),
));
}
Err(response) => {
yield Ok::<Event, Infallible>(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<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<ExecuteMatch3DAgentActionRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CompileMatch3DDraftRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, 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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PutMatch3DWorkRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PutMatch3DAudioAssetsRequest>, JsonRejection>,
) -> Result<Json<Value>, 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::<Vec<_>>();
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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PersistMatch3DGeneratedModelRequest>, JsonRejection>,
) -> Result<Json<Value>, 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::<Vec<_>>();
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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<GenerateMatch3DCoverImageRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<GenerateMatch3DBackgroundImageRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<GenerateMatch3DContainerImageRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<GenerateMatch3DItemAssetsRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<GenerateMatch3DWorkTagsRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<StartMatch3DRunRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<ClickMatch3DItemRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<StopMatch3DRunRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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),
},
))
}