- Route recommended runtime launches through shared runtime guest token handling - Extend recommend-page anonymous play beyond jump-hop - Add regression coverage for runtime guest launch clients - Update docs to reflect the full anonymous-play matrix
860 lines
30 KiB
Rust
860 lines
30 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::{Extension, Path, State, rejection::JsonRejection},
|
|
http::{HeaderName, StatusCode, header},
|
|
response::Response,
|
|
};
|
|
use module_assets::{
|
|
AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
|
|
generate_asset_binding_id, generate_asset_object_id,
|
|
};
|
|
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
|
|
use serde_json::{Value, json};
|
|
use shared_contracts::jump_hop::{
|
|
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
|
|
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
|
|
JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse,
|
|
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType,
|
|
JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse,
|
|
JumpHopWorkspaceCreateRequest,
|
|
};
|
|
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
|
use spacetime_client::SpacetimeClientError;
|
|
use std::{collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}};
|
|
|
|
use crate::{
|
|
api_response::json_success_body,
|
|
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
|
http_error::AppError,
|
|
generated_asset_sheets::{
|
|
GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt,
|
|
slice_generated_asset_sheet,
|
|
},
|
|
generated_image_assets::{
|
|
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
|
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
|
|
normalize_generated_image_asset_mime,
|
|
},
|
|
openai_image_generation::{
|
|
build_openai_image_http_client, create_openai_image_generation,
|
|
require_openai_image_settings,
|
|
},
|
|
request_context::RequestContext,
|
|
state::AppState,
|
|
work_play_tracking::{record_work_play_start_after_success, WorkPlayTrackingDraft},
|
|
};
|
|
|
|
const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] = ["start", "normal", "target", "finish", "bonus", "accent"];
|
|
|
|
const JUMP_HOP_PROVIDER: &str = "jump-hop";
|
|
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
|
|
const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime";
|
|
const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop";
|
|
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
|
|
const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs";
|
|
|
|
pub async fn create_jump_hop_session(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
payload: Result<Json<JumpHopWorkspaceCreateRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?;
|
|
validate_workspace_request(&request_context, &payload)?;
|
|
|
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
|
let session_id = build_prefixed_uuid_id("jump-hop-session-");
|
|
let now = current_utc_micros();
|
|
let draft = build_jump_hop_draft(&payload);
|
|
let session = JumpHopSessionSnapshotResponse {
|
|
session_id,
|
|
owner_user_id,
|
|
status: JumpHopGenerationStatus::Draft,
|
|
draft: Some(draft),
|
|
created_at: format_timestamp_micros(now),
|
|
updated_at: format_timestamp_micros(now),
|
|
};
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
JumpHopSessionResponse {
|
|
session: state
|
|
.spacetime_client()
|
|
.create_jump_hop_session(session)
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
&request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
map_jump_hop_client_error(error),
|
|
)
|
|
})?,
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn get_jump_hop_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, &session_id, "sessionId")?;
|
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
|
let session = state
|
|
.spacetime_client()
|
|
.get_jump_hop_session(session_id, owner_user_id)
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
&request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
map_jump_hop_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
JumpHopSessionResponse { session },
|
|
))
|
|
}
|
|
|
|
pub async fn execute_jump_hop_action(
|
|
State(state): State<AppState>,
|
|
Path(session_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
payload: Result<Json<JumpHopActionRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, Response> {
|
|
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
|
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?;
|
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
|
let mut payload = payload;
|
|
maybe_generate_jump_hop_assets(
|
|
&state,
|
|
&request_context,
|
|
session_id.as_str(),
|
|
owner_user_id.as_str(),
|
|
&mut payload,
|
|
)
|
|
.await?;
|
|
let response = state
|
|
.spacetime_client()
|
|
.execute_jump_hop_action(session_id, owner_user_id, payload)
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
&request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
map_jump_hop_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(Some(&request_context), response))
|
|
}
|
|
|
|
pub async fn publish_jump_hop_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, &profile_id, "profileId")?;
|
|
let work = state
|
|
.spacetime_client()
|
|
.publish_jump_hop_work(profile_id, authenticated.claims().user_id().to_string())
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
&request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
map_jump_hop_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
JumpHopWorkMutationResponse { item: work },
|
|
))
|
|
}
|
|
|
|
pub async fn list_jump_hop_works(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let works = state
|
|
.spacetime_client()
|
|
.list_jump_hop_works(authenticated.claims().user_id().to_string())
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
&request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
map_jump_hop_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
JumpHopWorksResponse {
|
|
items: works.into_iter().map(|work| work.summary).collect(),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn get_jump_hop_runtime_work(
|
|
State(state): State<AppState>,
|
|
Path(profile_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
) -> Result<Json<Value>, Response> {
|
|
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
|
let work = state
|
|
.spacetime_client()
|
|
.get_jump_hop_runtime_work(profile_id)
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
&request_context,
|
|
JUMP_HOP_RUNTIME_PROVIDER,
|
|
map_jump_hop_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
JumpHopWorkDetailResponse { item: work },
|
|
))
|
|
}
|
|
|
|
pub async fn start_jump_hop_run(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<RuntimePrincipal>,
|
|
payload: Result<Json<JumpHopStartRunRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
|
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
|
|
let owner_user_id = principal.subject().to_string();
|
|
let principal_kind = principal.kind().as_str();
|
|
let run = state
|
|
.spacetime_client()
|
|
.start_jump_hop_run(payload, owner_user_id.clone())
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
&request_context,
|
|
JUMP_HOP_RUNTIME_PROVIDER,
|
|
map_jump_hop_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
record_work_play_start_after_success(
|
|
&state,
|
|
&request_context,
|
|
build_jump_hop_work_play_tracking_draft(
|
|
&principal,
|
|
run.profile_id.clone(),
|
|
JUMP_HOP_RUNTIME_RUNS_ROUTE,
|
|
)
|
|
.owner_user_id(run.owner_user_id.clone())
|
|
.run_id(run.run_id.clone())
|
|
.profile_id(run.profile_id.clone())
|
|
.extra(json!({
|
|
"runStatus": run.status,
|
|
"principalKind": principal_kind,
|
|
})),
|
|
)
|
|
.await;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
JumpHopRunResponse { run },
|
|
))
|
|
}
|
|
|
|
pub async fn jump_hop_run_jump(
|
|
State(state): State<AppState>,
|
|
Path(run_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<RuntimePrincipal>,
|
|
payload: Result<Json<JumpHopJumpRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, Response> {
|
|
ensure_non_empty(&request_context, &run_id, "runId")?;
|
|
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
|
let owner_user_id = principal.subject().to_string();
|
|
let run = state
|
|
.spacetime_client()
|
|
.jump_hop_run_jump(run_id, owner_user_id, payload)
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
&request_context,
|
|
JUMP_HOP_RUNTIME_PROVIDER,
|
|
map_jump_hop_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
JumpHopJumpResponse { run },
|
|
))
|
|
}
|
|
|
|
pub async fn restart_jump_hop_run(
|
|
State(state): State<AppState>,
|
|
Path(run_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<RuntimePrincipal>,
|
|
payload: Result<Json<JumpHopRestartRunRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, Response> {
|
|
ensure_non_empty(&request_context, &run_id, "runId")?;
|
|
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
|
let owner_user_id = principal.subject().to_string();
|
|
let run = state
|
|
.spacetime_client()
|
|
.restart_jump_hop_run(run_id, owner_user_id, payload)
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
&request_context,
|
|
JUMP_HOP_RUNTIME_PROVIDER,
|
|
map_jump_hop_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
JumpHopRunResponse { run },
|
|
))
|
|
}
|
|
|
|
pub async fn list_jump_hop_gallery(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let gallery = state
|
|
.spacetime_client()
|
|
.list_jump_hop_gallery()
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
&request_context,
|
|
JUMP_HOP_RUNTIME_PROVIDER,
|
|
map_jump_hop_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(Some(&request_context), gallery))
|
|
}
|
|
|
|
pub async fn get_jump_hop_gallery_detail(
|
|
State(state): State<AppState>,
|
|
Path(public_work_code): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
) -> Result<Json<Value>, Response> {
|
|
ensure_non_empty(&request_context, &public_work_code, "publicWorkCode")?;
|
|
let work = state
|
|
.spacetime_client()
|
|
.get_jump_hop_gallery_detail(public_work_code)
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
&request_context,
|
|
JUMP_HOP_RUNTIME_PROVIDER,
|
|
map_jump_hop_client_error(error),
|
|
)
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
JumpHopGalleryDetailResponse { item: work },
|
|
))
|
|
}
|
|
|
|
async fn maybe_generate_jump_hop_assets(
|
|
state: &AppState,
|
|
request_context: &RequestContext,
|
|
session_id: &str,
|
|
owner_user_id: &str,
|
|
payload: &mut JumpHopActionRequest,
|
|
) -> Result<(), Response> {
|
|
if !matches!(payload.action_type, JumpHopActionType::CompileDraft) {
|
|
return Ok(());
|
|
}
|
|
if payload.character_asset.is_some()
|
|
&& payload.tile_atlas_asset.is_some()
|
|
&& payload.tile_assets.as_ref().is_some_and(|assets| !assets.is_empty())
|
|
{
|
|
return Ok(());
|
|
}
|
|
let profile_id = payload
|
|
.profile_id
|
|
.as_ref()
|
|
.map(|value| value.trim())
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToString::to_string)
|
|
.unwrap_or_else(|| build_prefixed_uuid_id("jump-hop-profile-"));
|
|
payload.profile_id = Some(profile_id.clone());
|
|
|
|
let settings = require_openai_image_settings(state)
|
|
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
|
let http_client = build_openai_image_http_client(&settings)
|
|
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
|
|
|
let character_prompt = payload
|
|
.character_prompt
|
|
.as_deref()
|
|
.unwrap_or("俯视角可爱主角,透明背景");
|
|
let tile_prompt = payload
|
|
.tile_prompt
|
|
.as_deref()
|
|
.unwrap_or("等距立体地块图集");
|
|
|
|
let character_generated = create_openai_image_generation(
|
|
&http_client,
|
|
&settings,
|
|
character_prompt,
|
|
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
|
|
"1024*1024",
|
|
1,
|
|
&[],
|
|
"跳一跳角色资产生成失败",
|
|
)
|
|
.await
|
|
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
|
let character_image = character_generated.images.into_iter().next().ok_or_else(|| {
|
|
jump_hop_error_response(
|
|
request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": "vector-engine",
|
|
"message": "跳一跳角色资产生成成功但未返回图片。",
|
|
})),
|
|
)
|
|
})?;
|
|
let character_asset = persist_jump_hop_generated_image_asset(
|
|
state,
|
|
owner_user_id,
|
|
profile_id.as_str(),
|
|
"character",
|
|
character_prompt,
|
|
character_image,
|
|
LegacyAssetPrefix::JumpHopAssets,
|
|
768,
|
|
768,
|
|
request_context,
|
|
)
|
|
.await?;
|
|
|
|
let sheet_prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput {
|
|
subject_text: tile_prompt,
|
|
item_names: &vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()],
|
|
grid_size: 3,
|
|
item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视图"),
|
|
special_prompt: Some("每个格子对应一个 tile 类型,供跳一跳地块裁切使用。"),
|
|
})
|
|
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
|
let tile_generated = create_openai_image_generation(
|
|
&http_client,
|
|
&settings,
|
|
sheet_prompt.as_str(),
|
|
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
|
|
"1024*1024",
|
|
1,
|
|
&[],
|
|
"跳一跳地块图集生成失败",
|
|
)
|
|
.await
|
|
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
|
let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| {
|
|
jump_hop_error_response(
|
|
request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": "vector-engine",
|
|
"message": "跳一跳地块图集生成成功但未返回图片。",
|
|
})),
|
|
)
|
|
})?;
|
|
let tile_slices = slice_generated_asset_sheet(
|
|
&tile_image,
|
|
&vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()],
|
|
3,
|
|
)
|
|
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
|
let tile_atlas_asset = persist_jump_hop_generated_image_asset(
|
|
state,
|
|
owner_user_id,
|
|
profile_id.as_str(),
|
|
"tile-atlas",
|
|
tile_prompt,
|
|
tile_image,
|
|
LegacyAssetPrefix::JumpHopAssets,
|
|
1024,
|
|
1024,
|
|
request_context,
|
|
)
|
|
.await?;
|
|
let tile_assets = tile_slices
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(index, row)| JumpHopTileAsset {
|
|
tile_type: match index {
|
|
0 => JumpHopTileType::Start,
|
|
1 => JumpHopTileType::Normal,
|
|
2 => JumpHopTileType::Target,
|
|
3 => JumpHopTileType::Finish,
|
|
4 => JumpHopTileType::Bonus,
|
|
_ => JumpHopTileType::Accent,
|
|
},
|
|
image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}.png"),
|
|
image_object_key: format!("generated-jump-hop-assets/{profile_id}/tiles/{index}.png"),
|
|
asset_object_id: format!("{profile_id}-tile-{index}-object"),
|
|
source_atlas_cell: format!("cell-{index}"),
|
|
visual_width: 256,
|
|
visual_height: 192,
|
|
top_surface_radius: 42.0,
|
|
landing_radius: 34.0,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
payload.character_asset = Some(character_asset);
|
|
payload.tile_atlas_asset = Some(tile_atlas_asset);
|
|
payload.tile_assets = Some(tile_assets);
|
|
payload.cover_composite = payload
|
|
.cover_composite
|
|
.clone()
|
|
.or_else(|| Some(format!("/generated-jump-hop-assets/{profile_id}/cover-composite.png")));
|
|
Ok(())
|
|
}
|
|
|
|
async fn persist_jump_hop_generated_image_asset(
|
|
state: &AppState,
|
|
owner_user_id: &str,
|
|
profile_id: &str,
|
|
slot: &str,
|
|
prompt: &str,
|
|
image: crate::openai_image_generation::DownloadedOpenAiImage,
|
|
prefix: LegacyAssetPrefix,
|
|
width: u32,
|
|
height: u32,
|
|
request_context: &RequestContext,
|
|
) -> Result<JumpHopCharacterAsset, Response> {
|
|
let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str());
|
|
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
|
prefix,
|
|
path_segments: vec![profile_id.to_string(), slot.to_string()],
|
|
file_stem: "image".to_string(),
|
|
image: GeneratedImageAssetDataUrl {
|
|
format: image_format,
|
|
bytes: image.bytes,
|
|
},
|
|
access: OssObjectAccess::Private,
|
|
metadata: GeneratedImageAssetAdapterMetadata {
|
|
asset_kind: Some(format!("jump-hop-{slot}")),
|
|
owner_user_id: Some(owner_user_id.to_string()),
|
|
entity_kind: Some("jump_hop_work".to_string()),
|
|
entity_id: Some(profile_id.to_string()),
|
|
slot: Some(slot.to_string()),
|
|
provider: Some("vector-engine".to_string()),
|
|
task_id: None,
|
|
},
|
|
extra_metadata: BTreeMap::new(),
|
|
})
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
|
"provider": "generated-image-assets",
|
|
"message": format!("准备跳一跳图片资产上传请求失败:{error:?}"),
|
|
})),
|
|
)
|
|
})?;
|
|
let persisted_mime_type = prepared.format.mime_type.clone();
|
|
let oss_client = state.oss_client().ok_or_else(|| {
|
|
jump_hop_error_response(
|
|
request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
|
"provider": "aliyun-oss",
|
|
"reason": "OSS 未完成环境变量配置",
|
|
})),
|
|
)
|
|
})?;
|
|
let http_client = reqwest::Client::new();
|
|
let put_result = oss_client
|
|
.put_object(&http_client, prepared.request)
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": "aliyun-oss",
|
|
"message": error.to_string(),
|
|
})),
|
|
)
|
|
})?;
|
|
let head = oss_client
|
|
.head_object(
|
|
&http_client,
|
|
OssHeadObjectRequest {
|
|
object_key: put_result.object_key.clone(),
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": "aliyun-oss",
|
|
"message": error.to_string(),
|
|
})),
|
|
)
|
|
})?;
|
|
let now_micros = current_utc_micros();
|
|
let asset_object_input = build_asset_object_upsert_input(
|
|
generate_asset_object_id(now_micros),
|
|
head.bucket,
|
|
head.object_key.clone(),
|
|
AssetObjectAccessPolicy::Private,
|
|
head.content_type.or(Some(persisted_mime_type)),
|
|
head.content_length,
|
|
head.etag,
|
|
format!("jump-hop-{slot}"),
|
|
None,
|
|
Some(owner_user_id.to_string()),
|
|
Some(profile_id.to_string()),
|
|
Some(profile_id.to_string()),
|
|
now_micros,
|
|
)
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "asset-object",
|
|
"message": error.to_string(),
|
|
})),
|
|
)
|
|
})?;
|
|
let asset_object = state
|
|
.spacetime_client()
|
|
.confirm_asset_object(asset_object_input)
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": "spacetimedb",
|
|
"message": error.to_string(),
|
|
})),
|
|
)
|
|
})?;
|
|
let binding_input = build_asset_entity_binding_input(
|
|
generate_asset_binding_id(now_micros),
|
|
asset_object.asset_object_id.clone(),
|
|
"jump_hop_work".to_string(),
|
|
profile_id.to_string(),
|
|
slot.to_string(),
|
|
format!("jump-hop-{slot}"),
|
|
Some(owner_user_id.to_string()),
|
|
Some(profile_id.to_string()),
|
|
now_micros,
|
|
)
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "asset-entity-binding",
|
|
"message": error.to_string(),
|
|
})),
|
|
)
|
|
})?;
|
|
state
|
|
.spacetime_client()
|
|
.bind_asset_object_to_entity(binding_input)
|
|
.await
|
|
.map_err(|error| {
|
|
jump_hop_error_response(
|
|
request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": "spacetimedb",
|
|
"message": error.to_string(),
|
|
})),
|
|
)
|
|
})?;
|
|
Ok(JumpHopCharacterAsset {
|
|
asset_id: format!("{profile_id}-{slot}-{now_micros}"),
|
|
image_src: put_result.legacy_public_path,
|
|
image_object_key: head.object_key,
|
|
asset_object_id: asset_object.asset_object_id,
|
|
generation_provider: "vector-engine".to_string(),
|
|
prompt: prompt.to_string(),
|
|
width,
|
|
height,
|
|
})
|
|
}
|
|
|
|
fn build_jump_hop_work_play_tracking_draft(
|
|
principal: &RuntimePrincipal,
|
|
work_id: impl Into<String>,
|
|
source_route: &'static str,
|
|
) -> WorkPlayTrackingDraft {
|
|
WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route)
|
|
}
|
|
|
|
|
|
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
|
|
JumpHopDraftResponse {
|
|
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
|
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
|
profile_id: None,
|
|
work_title: payload.work_title.trim().to_string(),
|
|
work_description: payload.work_description.trim().to_string(),
|
|
theme_tags: normalize_tags(payload.theme_tags.clone()),
|
|
difficulty: payload.difficulty.clone(),
|
|
style_preset: payload.style_preset.clone(),
|
|
character_prompt: payload.character_prompt.trim().to_string(),
|
|
tile_prompt: payload.tile_prompt.trim().to_string(),
|
|
end_mood_prompt: payload
|
|
.end_mood_prompt
|
|
.as_ref()
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty()),
|
|
character_asset: None,
|
|
tile_atlas_asset: None,
|
|
tile_assets: Vec::new(),
|
|
path: None,
|
|
cover_composite: None,
|
|
generation_status: JumpHopGenerationStatus::Draft,
|
|
}
|
|
}
|
|
|
|
fn validate_workspace_request(
|
|
request_context: &RequestContext,
|
|
payload: &JumpHopWorkspaceCreateRequest,
|
|
) -> Result<(), Response> {
|
|
ensure_non_empty(request_context, &payload.work_title, "workTitle")?;
|
|
ensure_non_empty(
|
|
request_context,
|
|
&payload.character_prompt,
|
|
"characterPrompt",
|
|
)?;
|
|
ensure_non_empty(request_context, &payload.tile_prompt, "tilePrompt")?;
|
|
if payload.template_id.trim() != JUMP_HOP_TEMPLATE_ID {
|
|
return Err(jump_hop_error_response(
|
|
request_context,
|
|
JUMP_HOP_CREATION_PROVIDER,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": JUMP_HOP_PROVIDER,
|
|
"message": "templateId 必须为 jump-hop",
|
|
})),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_non_empty(
|
|
request_context: &RequestContext,
|
|
value: &str,
|
|
field: &str,
|
|
) -> Result<(), Response> {
|
|
if value.trim().is_empty() {
|
|
return Err(jump_hop_error_response(
|
|
request_context,
|
|
JUMP_HOP_PROVIDER,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": JUMP_HOP_PROVIDER,
|
|
"field": field,
|
|
"message": format!("{field} 不能为空"),
|
|
})),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
|
let mut normalized = Vec::new();
|
|
for tag in tags {
|
|
let tag = tag.trim();
|
|
if tag.is_empty() || normalized.iter().any(|item| item == tag) {
|
|
continue;
|
|
}
|
|
normalized.push(tag.to_string());
|
|
if normalized.len() >= 6 {
|
|
break;
|
|
}
|
|
}
|
|
normalized
|
|
}
|
|
|
|
fn jump_hop_json<T>(
|
|
payload: Result<Json<T>, JsonRejection>,
|
|
request_context: &RequestContext,
|
|
provider: &str,
|
|
) -> Result<Json<T>, Response> {
|
|
payload.map_err(|error| {
|
|
jump_hop_error_response(
|
|
request_context,
|
|
provider,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": provider,
|
|
"message": error.to_string(),
|
|
})),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn map_jump_hop_client_error(error: SpacetimeClientError) -> AppError {
|
|
let status = match &error {
|
|
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
|
SpacetimeClientError::Procedure(message)
|
|
if message.contains("不存在")
|
|
|| message.contains("not found")
|
|
|| message.contains("does not exist") =>
|
|
{
|
|
StatusCode::NOT_FOUND
|
|
}
|
|
SpacetimeClientError::Procedure(message)
|
|
if message.contains("发布需要")
|
|
|| message.contains("不能为空")
|
|
|| message.contains("必须") =>
|
|
{
|
|
StatusCode::BAD_REQUEST
|
|
}
|
|
_ => StatusCode::BAD_GATEWAY,
|
|
};
|
|
|
|
AppError::from_status(status).with_details(json!({
|
|
"provider": "spacetimedb",
|
|
"message": error.to_string(),
|
|
}))
|
|
}
|
|
|
|
fn jump_hop_error_response(
|
|
request_context: &RequestContext,
|
|
provider: &str,
|
|
error: AppError,
|
|
) -> Response {
|
|
let mut response = error.into_response_with_context(Some(request_context));
|
|
response.headers_mut().insert(
|
|
HeaderName::from_static("x-genarrative-provider"),
|
|
header::HeaderValue::from_str(provider)
|
|
.unwrap_or_else(|_| header::HeaderValue::from_static("jump-hop")),
|
|
);
|
|
response
|
|
}
|
|
|
|
fn current_utc_micros() -> i64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_micros().min(i64::MAX as u128) as i64)
|
|
.unwrap_or(0)
|
|
}
|