Files
Genarrative/server-rs/crates/api-server/src/match3d.rs
T
kdletters f8a80cd795 修复资产计费边界风险
资产生成预扣费改为 fail-closed,避免钱包异常时继续调用外部生成

新增钱包退款 outbox,退款失败时本地落盘并后台重放

拼图首图后台任务改用 SpacetimeDB claim 表实现跨实例互斥

计费 ledger id 统一绑定 request_id,并让前端重试复用 x-request-id

同步 SpacetimeDB bindings、后端架构文档和 Hermes 决策记录
2026-06-11 15:55:23 +08:00

641 lines
22 KiB
Rust

use std::{
collections::BTreeMap,
convert::Infallible,
future::Future,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
http::{HeaderName, StatusCode, header},
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use futures_util::{StreamExt, stream::FuturesUnordered};
use image::ImageFormat;
use module_match3d::{
MATCH3D_MESSAGE_ID_PREFIX, MATCH3D_PROFILE_ID_PREFIX, MATCH3D_RUN_ID_PREFIX,
MATCH3D_SESSION_ID_PREFIX,
};
use platform_llm::{LlmMessage, LlmTextRequest};
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::{
creation_audio::CreationAudioAsset,
hyper3d as hyper3d_contract,
match3d_agent::{
CreateMatch3DAgentSessionRequest, ExecuteMatch3DAgentActionRequest,
Match3DAgentActionResponse, Match3DAgentMessageResponse, Match3DAgentSessionResponse,
Match3DAgentSessionSnapshotResponse, Match3DAnchorItemResponse, Match3DAnchorPackResponse,
Match3DCreatorConfigResponse,
Match3DGeneratedItemAssetResponse as Match3DAgentGeneratedItemAssetResponse,
Match3DResultDraftResponse, SendMatch3DAgentMessageRequest,
},
match3d_runtime::{
ClickMatch3DItemRequest, Match3DClickConfirmationResponse, Match3DClickResponse,
Match3DItemSnapshotResponse, Match3DRunResponse, Match3DRunSnapshotResponse,
Match3DTraySlotResponse, StartMatch3DRunRequest, StopMatch3DRunRequest,
},
match3d_works::{
GenerateMatch3DBackgroundImageRequest, GenerateMatch3DBackgroundImageResponse,
GenerateMatch3DContainerImageRequest, GenerateMatch3DContainerImageResponse,
GenerateMatch3DCoverImageRequest, GenerateMatch3DCoverImageResponse,
GenerateMatch3DItemAssetsRequest, GenerateMatch3DItemAssetsResponse,
Match3DWorkDetailResponse, Match3DWorkMutationResponse, Match3DWorkProfileResponse,
Match3DWorkSummaryResponse, Match3DWorksResponse, PersistMatch3DGeneratedModelRequest,
PersistMatch3DGeneratedModelResponse, PutMatch3DAudioAssetsRequest, PutMatch3DWorkRequest,
},
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
Match3DAgentMessageFinalizeRecordInput, Match3DAgentMessageRecord,
Match3DAgentMessageSubmitRecordInput, Match3DAgentSessionCreateRecordInput,
Match3DAgentSessionRecord, Match3DAnchorItemRecord, Match3DAnchorPackRecord,
Match3DClickConfirmationRecord, Match3DCompileDraftRecordInput, Match3DCreatorConfigRecord,
Match3DItemSnapshotRecord, Match3DResultDraftRecord, Match3DRunClickRecordInput,
Match3DRunRecord, Match3DRunRestartRecordInput, Match3DRunStartRecordInput,
Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput, Match3DTraySlotRecord,
Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput, SpacetimeClientError,
};
use crate::{
api_response::json_success_body,
asset_billing::{execute_billable_asset_operation_with_cost, map_asset_operation_wallet_error},
auth::{AuthenticatedAccessToken, RuntimePrincipal},
config::AppConfig,
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, OpenAiReferenceImage, build_openai_image_http_client,
create_openai_image_edit, create_openai_image_edit_with_references,
create_openai_image_generation, require_openai_image_settings,
},
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
vector_engine_audio_generation::{
GeneratedCreationAudioTarget, generate_sound_effect_asset_for_creation,
},
wechat::subscribe_message::{
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
send_generation_result_subscribe_message_after_completion,
},
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent";
const MATCH3D_WORKS_PROVIDER: &str = "match3d-works";
const MATCH3D_RUNTIME_PROVIDER: &str = "match3d-runtime";
const MATCH3D_DEFAULT_THEME: &str = "缤纷玩具";
const MATCH3D_DEFAULT_CLEAR_COUNT: u32 = 12;
const MATCH3D_DEFAULT_DIFFICULTY: u32 = 4;
const MATCH3D_DRAFT_GENERATION_POINTS_COST: u64 = 10;
const MATCH3D_BACKGROUND_IMAGE_POINTS_COST: u64 = 2;
const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH: u64 = 2;
const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 20;
const MATCH3D_ITEM_VIEW_COUNT: usize = 5;
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 10;
const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 20;
#[cfg(test)]
const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL: &str = "gemini-3-pro-image-preview";
#[cfg(test)]
const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO: &str = "1:1";
const MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS: u64 = 3 * 60_000;
const MATCH3D_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000;
const MATCH3D_LEGACY_MODEL_MAX_BYTES: usize = 120 * 1024 * 1024;
const MATCH3D_ITEM_IMAGE_MAX_BYTES: usize = 20 * 1024 * 1024;
const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o";
const MATCH3D_ITEM_SIZE_LARGE: &str = "大";
const MATCH3D_ITEM_SIZE_MEDIUM: &str = "中";
const MATCH3D_ITEM_SIZE_SMALL: &str = "小";
const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] =
include_bytes!("../../../../public/match3d-background-references/pot-fused-reference.png");
const MATCH3D_PUBLIC_REFERENCE_IMAGE_PATH_PREFIX: &str = "public/";
const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材";
const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关";
const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10,你要创作的关卡是难度几";
const MATCH3D_CLICK_SOUND_ASSET_KIND: &str = "match3d_click_sound";
const MATCH3D_PIXEL_RETRO_STYLE_PROMPT: &str = "真正复古像素 2D 游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。";
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DConfigJson {
theme_text: String,
reference_image_src: Option<String>,
clear_count: u32,
difficulty: u32,
#[serde(default)]
asset_style_id: Option<String>,
#[serde(default)]
asset_style_label: Option<String>,
#[serde(default)]
asset_style_prompt: Option<String>,
#[serde(default)]
generate_click_sound: bool,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedItemAsset {
item_id: String,
item_name: String,
item_size: Option<String>,
image_src: Option<String>,
image_object_key: Option<String>,
image_views: Vec<Match3DGeneratedItemImageView>,
model_src: Option<String>,
model_object_key: Option<String>,
model_file_name: Option<String>,
task_uuid: Option<String>,
subscription_key: Option<String>,
sound_prompt: Option<String>,
background_music_title: Option<String>,
background_music_style: Option<String>,
background_music_prompt: Option<String>,
background_music: Option<CreationAudioAsset>,
click_sound: Option<CreationAudioAsset>,
background_asset: Option<Match3DGeneratedBackgroundAsset>,
status: String,
error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct Match3DGeneratedItemImageView {
view_id: String,
view_index: u32,
#[serde(default)]
image_src: Option<String>,
#[serde(default)]
image_object_key: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DGeneratedBackgroundAsset {
prompt: String,
#[serde(default)]
level_scene_prompt: Option<String>,
#[serde(default)]
level_scene_image_src: Option<String>,
#[serde(default)]
level_scene_image_object_key: Option<String>,
#[serde(default)]
image_src: Option<String>,
#[serde(default)]
image_object_key: Option<String>,
#[serde(default)]
ui_spritesheet_prompt: Option<String>,
#[serde(default)]
ui_spritesheet_image_src: Option<String>,
#[serde(default)]
ui_spritesheet_image_object_key: Option<String>,
#[serde(default)]
item_spritesheet_prompt: Option<String>,
#[serde(default)]
item_spritesheet_image_src: Option<String>,
#[serde(default)]
item_spritesheet_image_object_key: Option<String>,
#[serde(default)]
container_prompt: Option<String>,
#[serde(default)]
container_image_src: Option<String>,
#[serde(default)]
container_image_object_key: Option<String>,
status: String,
#[serde(default)]
error: Option<String>,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedWorkMetadata {
game_name: String,
summary: String,
tags: Vec<String>,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedItemPlan {
name: String,
item_size: String,
sound_prompt: String,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedDraftPlan {
metadata: Match3DGeneratedWorkMetadata,
items: Vec<Match3DGeneratedItemPlan>,
background_prompt: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DGeneratedItemAssetJson {
item_id: String,
item_name: String,
#[serde(default)]
item_size: Option<String>,
#[serde(default)]
image_src: Option<String>,
#[serde(default)]
image_object_key: Option<String>,
#[serde(default)]
image_views: Vec<Match3DGeneratedItemImageView>,
#[serde(default)]
model_src: Option<String>,
#[serde(default)]
model_object_key: Option<String>,
#[serde(default)]
model_file_name: Option<String>,
#[serde(default)]
task_uuid: Option<String>,
#[serde(default)]
subscription_key: Option<String>,
#[serde(default)]
sound_prompt: Option<String>,
#[serde(default)]
background_music_title: Option<String>,
#[serde(default)]
background_music_style: Option<String>,
#[serde(default)]
background_music_prompt: Option<String>,
#[serde(default)]
background_music: Option<CreationAudioAsset>,
#[serde(default)]
click_sound: Option<CreationAudioAsset>,
#[serde(default)]
background_asset: Option<Match3DGeneratedBackgroundAsset>,
status: String,
#[serde(default)]
error: Option<String>,
}
#[derive(Clone, Debug)]
struct Match3DAssetUpload {
src: String,
object_key: String,
}
struct Match3DDownloadedModel {
bytes: Vec<u8>,
file_name: String,
content_type: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CompileMatch3DDraftRequest {
#[serde(default)]
game_name: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
tags: Option<Vec<String>>,
#[serde(default)]
cover_image_src: Option<String>,
#[serde(default)]
generate_click_sound: Option<bool>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateMatch3DWorkTagsRequest {
game_name: String,
theme_text: String,
#[serde(default)]
summary: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateMatch3DWorkTagsResponse {
tags: Vec<String>,
}
struct Match3DWorkAssetContext {
owner_user_id: String,
session_id: String,
profile: Match3DWorkProfileRecord,
config: Match3DConfigJson,
assets: Vec<Match3DGeneratedItemAsset>,
}
struct Match3DItemAssetAppendPlan {
requested_item_names: Vec<String>,
padded_item_names: Vec<String>,
}
struct Match3DItemAssetReplacePlan {
requested_item_names: Vec<String>,
padded_item_names: Vec<String>,
target_assets: Vec<Match3DGeneratedItemAsset>,
}
enum Match3DItemAssetsGenerationPlan {
Append(Match3DItemAssetAppendPlan),
Replace(Match3DItemAssetReplacePlan),
}
enum Match3DItemAssetsGenerationMode {
Append,
Replace,
}
impl Match3DItemAssetsGenerationPlan {
fn billed_item_count(&self) -> usize {
match self {
Self::Append(plan) => plan.requested_item_names.len(),
Self::Replace(plan) => plan.requested_item_names.len(),
}
}
}
fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
if assets.is_empty() {
return None;
}
let items = assets
.iter()
.cloned()
.map(Match3DGeneratedItemAssetJson::from)
.collect::<Vec<_>>();
serde_json::to_string(&items).ok()
}
fn parse_match3d_generated_item_assets(value: Option<&str>) -> Vec<Match3DGeneratedItemAssetJson> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| serde_json::from_str::<Vec<Match3DGeneratedItemAssetJson>>(value).ok())
.unwrap_or_default()
}
impl From<Match3DGeneratedItemAsset> for Match3DGeneratedItemAssetJson {
fn from(asset: Match3DGeneratedItemAsset) -> Self {
Self {
item_id: asset.item_id,
item_name: asset.item_name,
item_size: asset.item_size,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
image_views: asset.image_views,
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
sound_prompt: asset.sound_prompt,
background_music_title: asset.background_music_title,
background_music_style: asset.background_music_style,
background_music_prompt: asset.background_music_prompt,
background_music: asset.background_music,
click_sound: asset.click_sound,
background_asset: asset.background_asset,
status: asset.status,
error: asset.error,
}
}
}
impl From<Match3DGeneratedItemAssetJson> for Match3DGeneratedItemAsset {
fn from(asset: Match3DGeneratedItemAssetJson) -> Self {
Self {
item_id: asset.item_id,
item_name: asset.item_name,
item_size: asset
.item_size
.or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())),
image_src: asset.image_src,
image_object_key: asset.image_object_key,
image_views: asset.image_views,
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
sound_prompt: asset.sound_prompt,
background_music_title: asset.background_music_title,
background_music_style: asset.background_music_style,
background_music_prompt: asset.background_music_prompt,
background_music: asset.background_music,
click_sound: asset.click_sound,
background_asset: asset.background_asset,
status: asset.status,
error: asset.error,
}
}
}
impl From<shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse>
for Match3DGeneratedItemAsset
{
fn from(asset: shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse) -> Self {
Self {
item_id: asset.item_id,
item_name: asset.item_name,
item_size: asset.item_size,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
image_views: asset
.image_views
.into_iter()
.map(map_match3d_image_view_from_work)
.collect(),
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
sound_prompt: asset.sound_prompt,
background_music_title: asset.background_music_title,
background_music_style: asset.background_music_style,
background_music_prompt: asset.background_music_prompt,
background_music: asset.background_music,
click_sound: asset.click_sound,
background_asset: asset
.background_asset
.map(|asset| Match3DGeneratedBackgroundAsset {
prompt: asset.prompt,
level_scene_prompt: asset.level_scene_prompt,
level_scene_image_src: asset.level_scene_image_src,
level_scene_image_object_key: asset.level_scene_image_object_key,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
ui_spritesheet_prompt: asset.ui_spritesheet_prompt,
ui_spritesheet_image_src: asset.ui_spritesheet_image_src,
ui_spritesheet_image_object_key: asset.ui_spritesheet_image_object_key,
item_spritesheet_prompt: asset.item_spritesheet_prompt,
item_spritesheet_image_src: asset.item_spritesheet_image_src,
item_spritesheet_image_object_key: asset.item_spritesheet_image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
status: asset.status,
error: asset.error,
}),
status: asset.status,
error: asset.error,
}
}
}
mod handlers;
pub(crate) use self::handlers::*;
mod mappers;
use self::mappers::*;
mod tags;
use self::tags::*;
mod draft;
use self::draft::*;
mod works;
use self::works::*;
mod runtime;
use self::runtime::*;
mod item_assets;
use self::item_assets::*;
#[cfg(test)]
mod vector_engine_gemini;
#[cfg(test)]
use self::vector_engine_gemini::*;
fn ensure_non_empty(
request_context: &RequestContext,
provider: &str,
value: &str,
field_name: &str,
) -> Result<(), Response> {
if value.trim().is_empty() {
return Err(match3d_bad_request(
request_context,
provider,
format!("{field_name} is required").as_str(),
));
}
Ok(())
}
fn match3d_mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
"image/jpeg" | "image/jpg" => "jpg",
_ => "png",
}
}
fn match3d_json<T>(
payload: Result<Json<T>, JsonRejection>,
request_context: &RequestContext,
provider: &str,
) -> Result<Json<T>, Response> {
payload.map_err(|error| {
match3d_error_response(
request_context,
provider,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": provider,
"message": error.body_text(),
})),
)
})
}
fn match3d_bad_request(
request_context: &RequestContext,
provider: &str,
message: &str,
) -> Response {
match3d_error_response(
request_context,
provider,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": provider,
"message": message,
})),
)
}
fn map_match3d_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 match3d_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("match3d")),
);
response
}
fn match3d_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
Event::default()
.event(event_name)
.json_data(payload)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "sse",
"message": format!("SSE payload 序列化失败:{error}"),
}))
})
}
fn match3d_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
match match3d_sse_json_event(event_name, payload) {
Ok(event) => event,
Err(error) => Event::default().event("error").data(format!("{error:?}")),
}
}
fn current_utc_micros() -> i64 {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
(duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros())
}
fn current_utc_ms() -> i64 {
current_utc_micros().saturating_div(1000)
}
#[cfg(test)]
mod tests;