feat: checkpoint m5 and bootstrap m6 asset flow

This commit is contained in:
2026-04-22 14:46:43 +08:00
parent 0773a0d0ca
commit 91fb8edee7
22 changed files with 5096 additions and 445 deletions

View File

@@ -44,6 +44,12 @@
22. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract
23. 接入 `custom-world-library``custom-world-gallery` 与 agent `publish_world` 首批 Axum facade
24. 接入 custom world agent `session create / session snapshot` Axum facade
25. 接入旧 `runtime story` 兼容接口:
- `POST /api/runtime/story/state/resolve`
- `GET /api/runtime/story/state/{session_id}`
- `POST /api/runtime/story/actions/resolve`
- `POST /api/runtime/story/initial`
- `POST /api/runtime/story/continue`
后续与本 crate 直接相关的任务包括:
@@ -68,6 +74,7 @@
19. [x] 接入 `/api/assets/sts-upload-credentials`
20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade
21. [x] 接入 `custom world agent session create / snapshot` facade
22. [x] 接入旧 `runtime story` compat facade
当前 tracing 约定:
@@ -136,3 +143,4 @@
12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB而是统一换成系统签发的 JWT。
13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。
14. 当前 `/api/runtime/custom-world/agent/sessions``/api/runtime/custom-world/agent/sessions/{session_id}` 只提供 deterministic session 骨架与 snapshot 读取,不承诺 message submit、operation query、card detail 的完整能力。
15. 当前 `/api/runtime/story/*` 已在 Rust 侧补齐 compat handler但内部仍是 `runtime_snapshot` 驱动的兼容桥与确定性动作编排,不应误判为真正的 SpacetimeDB `resolve_story_action` 真相链已完成。

View File

@@ -26,11 +26,9 @@ use crate::{
auth_sessions::auth_sessions,
custom_world::{
create_custom_world_agent_session, execute_custom_world_agent_action,
get_custom_world_agent_card_detail,
get_custom_world_agent_operation, get_custom_world_agent_session,
get_custom_world_works,
get_custom_world_gallery_detail, get_custom_world_library,
get_custom_world_library_detail, list_custom_world_gallery,
get_custom_world_agent_card_detail, get_custom_world_agent_operation,
get_custom_world_agent_session, get_custom_world_gallery_detail, get_custom_world_library,
get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery,
publish_custom_world_library_profile, put_custom_world_library_profile,
stream_custom_world_agent_message, submit_custom_world_agent_message,
unpublish_custom_world_library_profile,
@@ -62,8 +60,8 @@ use crate::{
},
runtime_settings::{get_runtime_settings, put_runtime_settings},
runtime_story::{
generate_runtime_story_continue, generate_runtime_story_initial,
get_runtime_story_state, resolve_runtime_story_action, resolve_runtime_story_state,
generate_runtime_story_continue, generate_runtime_story_initial, get_runtime_story_state,
resolve_runtime_story_action, resolve_runtime_story_state,
},
state::AppState,
story_battles::{
@@ -242,9 +240,9 @@ pub fn build_router(state: AppState) -> Router {
get(get_runtime_settings)
.put(put_runtime_settings)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/save/snapshot",
@@ -316,9 +314,10 @@ pub fn build_router(state: AppState) -> Router {
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}",
get(get_custom_world_agent_card_detail).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
get(get_custom_world_agent_card_detail).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/messages",
@@ -364,45 +363,52 @@ pub fn build_router(state: AppState) -> Router {
)
.route(
"/api/custom-world/scene-npc",
post(generate_custom_world_scene_npc).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/scene-npc",
post(generate_custom_world_scene_npc).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/scene-image",
post(generate_custom_world_scene_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
post(generate_custom_world_scene_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/cover-image",
post(generate_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/cover-image",
post(generate_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/cover-upload",
post(upload_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/cover-upload",
post(upload_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/browse-history",

View File

@@ -1,8 +1,8 @@
use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
http::{HeaderName, StatusCode, header},
response::{IntoResponse, Response},
http::StatusCode,
response::Response,
};
use module_custom_world::{
CustomWorldThemeMode, empty_agent_anchor_content_json, empty_agent_asset_coverage_json,
@@ -10,35 +10,34 @@ use module_custom_world::{
};
use serde_json::{Map, Value, json};
use shared_contracts::runtime::{
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCheckpointResponse,
CustomWorldAgentCardDetailResponse,
CustomWorldAgentMessageResponse, CustomWorldAgentOperationResponse,
CustomWorldAgentSessionResponse, CustomWorldAgentSessionSnapshotResponse,
CustomWorldDraftCardDetailResponse, CustomWorldDraftCardDetailSectionResponse,
CustomWorldDraftCardSummaryResponse, CustomWorldGalleryCardResponse,
CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryEntryResponse,
CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse,
CustomWorldProfileUpsertRequest, CustomWorldSupportedActionResponse,
CustomWorldPublishGateResponse, CustomWorldResultPreviewBlockerResponse,
CustomWorldWorkSummaryResponse, CustomWorldWorksResponse,
ExecuteCustomWorldAgentActionRequest, SendCustomWorldAgentMessageRequest,
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCardDetailResponse,
CustomWorldAgentCheckpointResponse, CustomWorldAgentMessageResponse,
CustomWorldAgentOperationResponse, CustomWorldAgentSessionResponse,
CustomWorldAgentSessionSnapshotResponse, CustomWorldDraftCardDetailResponse,
CustomWorldDraftCardDetailSectionResponse, CustomWorldDraftCardSummaryResponse,
CustomWorldGalleryCardResponse, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse,
CustomWorldLibraryEntryResponse, CustomWorldLibraryMutationResponse,
CustomWorldLibraryResponse, CustomWorldProfileUpsertRequest, CustomWorldPublishGateResponse,
CustomWorldResultPreviewBlockerResponse, CustomWorldSupportedActionResponse,
CustomWorldWorkSummaryResponse, CustomWorldWorksResponse, ExecuteCustomWorldAgentActionRequest,
SendCustomWorldAgentMessageRequest,
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
CustomWorldAgentActionExecuteRecordInput,
CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord,
CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput,
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
CustomWorldResultPreviewBlockerRecord, CustomWorldWorkSummaryRecord,
CustomWorldSupportedActionRecord, SpacetimeClientError,
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
CustomWorldWorkSummaryRecord, SpacetimeClientError,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
request_context::RequestContext, sse::SseEventBuffer, state::AppState,
};
pub async fn get_custom_world_library(
@@ -84,10 +83,7 @@ pub async fn get_custom_world_library_detail(
let detail = state
.spacetime_client()
.get_custom_world_library_detail(
authenticated.claims().user_id().to_string(),
profile_id,
)
.get_custom_world_library_detail(authenticated.claims().user_id().to_string(), profile_id)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
@@ -582,19 +578,15 @@ pub async fn stream_custom_world_agent_message(
// 这里先用“一次性构造完整 SSE 文本”的最小兼容方案,
// 复用 Stage 7 的同步 deterministic 写表逻辑,保证前端当前的 reader 协议可直接消费。
let mut sse_body = String::new();
append_sse_event(&mut sse_body, "reply_delta", &json!({ "text": reply_text }))
let mut sse = SseEventBuffer::new();
sse.push_json("reply_delta", &json!({ "text": reply_text }))
.map_err(|error| custom_world_error_response(&request_context, error))?;
append_sse_event(
&mut sse_body,
"session",
&json!({ "session": session_response }),
)
.map_err(|error| custom_world_error_response(&request_context, error))?;
append_sse_event(&mut sse_body, "done", &json!({ "ok": true }))
sse.push_json("session", &json!({ "session": session_response }))
.map_err(|error| custom_world_error_response(&request_context, error))?;
sse.push_json("done", &json!({ "ok": true }))
.map_err(|error| custom_world_error_response(&request_context, error))?;
Ok(build_event_stream_response(sse_body))
Ok(sse.into_response())
}
pub async fn get_custom_world_agent_operation(
@@ -815,7 +807,9 @@ fn map_custom_world_agent_session_response(
.into_iter()
.map(map_custom_world_supported_action_response)
.collect(),
publish_gate: session.publish_gate.map(map_custom_world_publish_gate_response),
publish_gate: session
.publish_gate
.map(map_custom_world_publish_gate_response),
result_preview: session.result_preview,
updated_at: session.updated_at,
}
@@ -958,40 +952,11 @@ fn resolve_stream_reply_text(session: &CustomWorldAgentSessionSnapshotResponse)
.unwrap_or_default()
}
fn append_sse_event(body: &mut String, event: &str, payload: &Value) -> Result<(), AppError> {
let payload_text = serde_json::to_string(payload).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-agent",
"message": format!("SSE payload 序列化失败:{error}"),
}))
})?;
body.push_str("event: ");
body.push_str(event);
body.push('\n');
body.push_str("data: ");
body.push_str(&payload_text);
body.push_str("\n\n");
Ok(())
}
fn build_event_stream_response(body: String) -> Response {
(
[
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
// 反向代理场景下显式关闭缓冲,避免 SSE 事件被聚合后才下发。
(HeaderName::from_static("x-accel-buffering"), "no"),
],
body,
)
.into_response()
}
fn map_custom_world_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Procedure(message) if message.contains("custom_world_profile 不存在") => {
SpacetimeClientError::Procedure(message)
if message.contains("custom_world_profile 不存在") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)

View File

@@ -1,7 +1,4 @@
use std::{
fs,
path::{Path, PathBuf},
};
use std::collections::BTreeMap;
use axum::{
Json,
@@ -9,9 +6,15 @@ use axum::{
http::StatusCode,
response::Response,
};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use platform_llm::{LlmMessage, LlmTextRequest};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
@@ -87,6 +90,20 @@ struct GeneratedAssetResponse {
actual_prompt: Option<String>,
}
struct PreparedAssetUpload {
prefix: LegacyAssetPrefix,
path_segments: Vec<String>,
file_name: String,
content_type: String,
body: Vec<u8>,
asset_kind: &'static str,
entity_kind: &'static str,
entity_id: String,
profile_id: Option<String>,
slot: &'static str,
source_job_id: Option<String>,
}
pub async fn generate_custom_world_entity(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -160,8 +177,9 @@ pub async fn generate_custom_world_scene_npc(
}
pub async fn generate_custom_world_scene_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldSceneImageRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -174,30 +192,76 @@ pub async fn generate_custom_world_scene_image(
)
})?;
let asset = save_placeholder_asset(
"generated-custom-world-scenes",
payload
.profile_id
let owner_user_id = authenticated.claims().user_id().to_string();
let profile_id = trim_to_option(payload.profile_id.as_deref());
let world_name =
trim_to_option(payload.world_name.as_deref()).unwrap_or_else(|| "world".to_string());
let landmark_id = trim_to_option(payload.landmark_id.as_deref());
let landmark_name =
trim_to_option(payload.landmark_name.as_deref()).unwrap_or_else(|| "scene".to_string());
let entity_id = landmark_id.clone().unwrap_or_else(|| landmark_name.clone());
let size = payload
.size
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("1280*720")
.to_string();
let prompt = trim_to_option(payload.prompt.as_deref());
let asset_id = format!("custom-scene-{}", current_utc_millis());
let svg = build_placeholder_svg(
&size,
prompt
.as_deref()
.or(payload.world_name.as_deref())
.unwrap_or("world"),
payload
.landmark_id
.as_deref()
.or(payload.landmark_name.as_deref())
.or(Some(landmark_name.as_str()))
.unwrap_or("scene"),
"scene",
payload.size.as_deref().unwrap_or("1280*720"),
payload.prompt.as_deref(),
)
.into_bytes();
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
sanitize_storage_segment(
profile_id.as_deref().unwrap_or(world_name.as_str()),
"world",
),
sanitize_storage_segment(entity_id.as_str(), "scene"),
asset_id.clone(),
],
file_name: "scene.svg".to_string(),
content_type: "image/svg+xml".to_string(),
body: svg,
asset_kind: "scene_image",
entity_kind: "custom_world_landmark",
entity_id,
profile_id,
slot: "scene_image",
source_job_id: Some(asset_id.clone()),
};
let asset = persist_custom_world_asset(
&state,
&owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some("rust-oss-placeholder".to_string()),
size: Some(size),
task_id: Some(asset_id),
prompt: prompt.clone(),
actual_prompt: prompt,
},
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), asset))
}
pub async fn generate_custom_world_cover_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldCoverImageRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -210,24 +274,69 @@ pub async fn generate_custom_world_cover_image(
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let profile = payload.profile.as_object().cloned().unwrap_or_default();
let profile_id = read_string_field(&profile, "id");
let world_name = read_string_field(&profile, "name").unwrap_or_else(|| "world".to_string());
let asset = save_placeholder_asset(
"generated-custom-world-covers",
&read_string_field(&profile, "id").unwrap_or_else(|| world_name.clone()),
"cover",
"cover",
payload.size.as_deref().unwrap_or("1600*900"),
payload.user_prompt.as_deref(),
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
let size = payload
.size
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("1600*900")
.to_string();
let prompt = trim_to_option(payload.user_prompt.as_deref());
let asset_id = format!("custom-cover-{}", current_utc_millis());
let svg = build_placeholder_svg(
&size,
prompt
.as_deref()
.or(Some(world_name.as_str()))
.unwrap_or("cover"),
)
.into_bytes();
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldCovers,
path_segments: vec![
sanitize_storage_segment(entity_id.as_str(), "world"),
asset_id.clone(),
],
file_name: "cover.svg".to_string(),
content_type: "image/svg+xml".to_string(),
body: svg,
asset_kind: "custom_world_cover",
entity_kind: "custom_world_profile",
entity_id,
profile_id,
slot: "cover",
source_job_id: Some(asset_id.clone()),
};
let asset = persist_custom_world_asset(
&state,
&owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some("rust-oss-placeholder".to_string()),
size: Some(size),
task_id: Some(asset_id),
prompt: prompt.clone(),
actual_prompt: prompt,
},
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), asset))
}
pub async fn upload_custom_world_cover_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldCoverUploadRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -249,41 +358,41 @@ pub async fn upload_custom_world_cover_image(
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let profile_id = trim_to_option(payload.profile_id.as_deref());
let world_name =
trim_to_option(payload.world_name.as_deref()).unwrap_or_else(|| "world".to_string());
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
let asset_id = format!("custom-cover-upload-{}", current_utc_millis());
let world_segment = sanitize_path_segment(
payload
.profile_id
.as_deref()
.or(payload.world_name.as_deref())
.unwrap_or("world"),
"world",
);
let relative_dir = PathBuf::from("generated-custom-world-covers")
.join(world_segment)
.join(&asset_id);
let output_dir = resolve_public_output_dir(&relative_dir)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
fs::create_dir_all(&output_dir)
.map_err(io_error)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let file_name = match parsed.mime_type.as_str() {
"image/png" => "cover.png",
"image/webp" => "cover.webp",
"image/svg+xml" => "cover.svg",
_ => "cover.jpg",
}
.to_string();
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldCovers,
path_segments: vec![
sanitize_storage_segment(entity_id.as_str(), "world"),
asset_id.clone(),
],
file_name,
content_type: parsed.mime_type,
body: parsed.bytes,
asset_kind: "custom_world_cover",
entity_kind: "custom_world_profile",
entity_id,
profile_id,
slot: "cover",
source_job_id: Some(asset_id.clone()),
};
fs::write(output_dir.join(file_name), parsed.bytes)
.map_err(io_error)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let image_src = format!(
"/{}/{}",
relative_dir.to_string_lossy().replace('\\', "/"),
file_name
);
Ok(json_success_body(
Some(&request_context),
let asset = persist_custom_world_asset(
&state,
&owner_user_id,
upload,
GeneratedAssetResponse {
image_src,
image_src: String::new(),
asset_id,
source_type: "uploaded".to_string(),
model: None,
@@ -292,14 +401,162 @@ pub async fn upload_custom_world_cover_image(
prompt: None,
actual_prompt: None,
},
))
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), asset))
}
async fn generate_entity_with_fallback(
async fn persist_custom_world_asset(
state: &AppState,
profile: &Value,
kind: &str,
) -> Value {
owner_user_id: &str,
upload: PreparedAssetUpload,
mut response: GeneratedAssetResponse,
) -> Result<GeneratedAssetResponse, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
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,
OssPutObjectRequest {
prefix: upload.prefix,
path_segments: upload.path_segments,
file_name: upload.file_name,
content_type: Some(upload.content_type.clone()),
access: OssObjectAccess::Private,
metadata: build_asset_metadata(
upload.asset_kind,
owner_user_id,
upload.profile_id.as_deref(),
upload.entity_kind,
upload.entity_id.as_str(),
upload.slot,
),
body: upload.body,
},
)
.await
.map_err(map_custom_world_asset_oss_error)?;
// custom world 图片链正式改为 OSS 真值确认,不再把 put_object 返回值直接当成唯一对象真相。
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(map_custom_world_asset_oss_error)?;
let now_micros = current_utc_micros();
let asset_object = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(upload.content_type)),
head.content_length,
head.etag,
upload.asset_kind.to_string(),
upload.source_job_id,
Some(owner_user_id.to_string()),
upload.profile_id.clone(),
Some(upload.entity_id.clone()),
now_micros,
)
.map_err(map_asset_object_prepare_error)?,
)
.await
.map_err(map_custom_world_asset_spacetime_error)?;
state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
asset_object.asset_object_id,
upload.entity_kind.to_string(),
upload.entity_id,
upload.slot.to_string(),
upload.asset_kind.to_string(),
Some(owner_user_id.to_string()),
upload.profile_id,
now_micros,
)
.map_err(map_asset_binding_prepare_error)?,
)
.await
.map_err(map_custom_world_asset_spacetime_error)?;
response.image_src = put_result.legacy_public_path;
Ok(response)
}
fn build_asset_metadata(
asset_kind: &str,
owner_user_id: &str,
profile_id: Option<&str>,
entity_kind: &str,
entity_id: &str,
slot: &str,
) -> BTreeMap<String, String> {
let mut metadata = BTreeMap::from([
("asset_kind".to_string(), asset_kind.to_string()),
("owner_user_id".to_string(), owner_user_id.to_string()),
("entity_kind".to_string(), entity_kind.to_string()),
("entity_id".to_string(), entity_id.to_string()),
("slot".to_string(), slot.to_string()),
]);
if let Some(profile_id) = profile_id {
metadata.insert("profile_id".to_string(), profile_id.to_string());
}
metadata
}
fn map_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
}
fn map_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-entity-binding",
"message": error.to_string(),
}))
}
fn map_custom_world_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
}
async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value {
let fallback = build_entity_fallback(profile, kind);
let Some(llm_client) = state.llm_client() else {
return fallback;
@@ -447,37 +704,6 @@ fn build_landmark_fallback(world_name: &str) -> Value {
})
}
fn save_placeholder_asset(
root_segment: &str,
world_segment_seed: &str,
leaf_segment_seed: &str,
file_stem: &str,
size: &str,
prompt: Option<&str>,
) -> Result<GeneratedAssetResponse, AppError> {
let asset_id = format!("{file_stem}-{}", current_utc_millis());
let relative_dir = PathBuf::from(root_segment)
.join(sanitize_path_segment(world_segment_seed, "world"))
.join(sanitize_path_segment(leaf_segment_seed, file_stem))
.join(&asset_id);
let output_dir = resolve_public_output_dir(&relative_dir)?;
fs::create_dir_all(&output_dir).map_err(io_error)?;
let file_name = format!("{file_stem}.svg");
let svg = build_placeholder_svg(size, prompt.unwrap_or(file_stem));
fs::write(output_dir.join(&file_name), svg).map_err(io_error)?;
Ok(GeneratedAssetResponse {
image_src: format!("/{}/{}", relative_dir.to_string_lossy().replace('\\', "/"), file_name),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some("rust-placeholder".to_string()),
size: Some(size.to_string()),
task_id: Some(asset_id),
prompt: prompt.map(ToOwned::to_owned),
actual_prompt: prompt.map(ToOwned::to_owned),
})
}
fn build_placeholder_svg(size: &str, label: &str) -> String {
let (width, height) = parse_size(size);
format!(
@@ -493,7 +719,7 @@ fn build_placeholder_svg(size: &str, label: &str) -> String {
<circle cx="{cx1}" cy="{cy1}" r="{r1}" fill="rgba(255,255,255,0.12)"/>
<circle cx="{cx2}" cy="{cy2}" r="{r2}" fill="rgba(125,211,252,0.14)"/>
<text x="50%" y="46%" text-anchor="middle" fill="#e2e8f0" font-size="{font_main}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
<text x="50%" y="56%" text-anchor="middle" fill="#bae6fd" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">Rust fallback asset</text>
<text x="50%" y="56%" text-anchor="middle" fill="#bae6fd" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">Rust OSS placeholder</text>
</svg>"##,
width = width,
height = height,
@@ -531,36 +757,41 @@ fn escape_svg_text(value: &str) -> String {
.replace('>', "&gt;")
}
fn sanitize_path_segment(value: &str, fallback: &str) -> String {
let sanitized = value
fn sanitize_storage_segment(value: &str, fallback: &str) -> String {
let normalized = value
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) {
ch
} else {
'-'
}
.map(|character| match character {
'a'..='z' | '0'..='9' | '-' | '_' => character,
'A'..='Z' => character.to_ascii_lowercase(),
_ => '-',
})
.collect::<String>()
.trim_matches('-')
.to_string();
if sanitized.is_empty() {
.collect::<String>();
let normalized = collapse_dashes(&normalized);
if normalized.is_empty() {
fallback.to_string()
} else {
sanitized
normalized
}
}
fn resolve_public_output_dir(relative_dir: &Path) -> Result<PathBuf, AppError> {
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.ok_or_else(|| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message("无法解析仓库根目录")
})?;
Ok(workspace_root.join("public").join(relative_dir))
fn collapse_dashes(value: &str) -> String {
value
.chars()
.fold(
(String::new(), false),
|(mut output, last_is_dash), character| {
let is_dash = character == '-';
if is_dash && last_is_dash {
return (output, true);
}
output.push(character);
(output, is_dash)
},
)
.0
.trim_matches('-')
.to_string()
}
fn parse_image_data_url(value: &str) -> Option<ParsedImageDataUrl> {
@@ -568,6 +799,9 @@ fn parse_image_data_url(value: &str) -> Option<ParsedImageDataUrl> {
let separator = ";base64,";
let body = value.strip_prefix(prefix)?;
let (mime_type, data) = body.split_once(separator)?;
if !mime_type.starts_with("image/") {
return None;
}
let bytes = decode_base64(data)?;
Some(ParsedImageDataUrl {
mime_type: mime_type.to_string(),
@@ -611,6 +845,13 @@ fn read_string_field(object: &Map<String, Value>, key: &str) -> Option<String> {
.map(ToOwned::to_owned)
}
fn trim_to_option(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn current_utc_millis() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
@@ -619,11 +860,12 @@ fn current_utc_millis() -> i64 {
i64::try_from(duration.as_millis()).expect("current unix millis should fit in i64")
}
fn io_error(error: std::io::Error) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-ai",
"message": format!("文件写入失败:{error}"),
}))
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
fn custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response {
@@ -634,3 +876,159 @@ struct ParsedImageDataUrl {
mime_type: String,
bytes: Vec<u8>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
use axum::response::Response;
use platform_auth::{AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus};
use serde_json::Value;
use time::OffsetDateTime;
fn build_authenticated(state: &AppState) -> AuthenticatedAccessToken {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_custom_world_ai".to_string(),
session_id: "sess_custom_world_ai".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some("测试旅人".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
AuthenticatedAccessToken::new(claims)
}
fn build_request_context(operation: &str) -> RequestContext {
RequestContext::new(
"req-custom-world-ai-test".to_string(),
operation.to_string(),
std::time::Duration::ZERO,
true,
)
}
async fn read_error_response(response: Response) -> Value {
use http_body_util::BodyExt as _;
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
serde_json::from_slice(&body).expect("body should be valid json")
}
#[tokio::test]
async fn scene_image_returns_service_unavailable_when_oss_missing() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/custom-world/scene-image");
let authenticated = build_authenticated(&state);
let response = generate_custom_world_scene_image(
State(state),
Extension(request_context),
Extension(authenticated),
Ok(Json(CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()),
world_name: Some("世界".to_string()),
landmark_id: Some("landmark_001".to_string()),
landmark_name: Some("遗迹".to_string()),
prompt: Some("测试场景".to_string()),
size: Some("1280*720".to_string()),
})),
)
.await
.expect_err("missing oss should fail");
let payload = read_error_response(response).await;
assert_eq!(
payload["error"]["code"],
Value::String("SERVICE_UNAVAILABLE".to_string())
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("aliyun-oss".to_string())
);
}
#[tokio::test]
async fn cover_image_returns_service_unavailable_when_oss_missing() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/custom-world/cover-image");
let authenticated = build_authenticated(&state);
let response = generate_custom_world_cover_image(
State(state),
Extension(request_context),
Extension(authenticated),
Ok(Json(CustomWorldCoverImageRequest {
profile: json!({
"id": "profile_001",
"name": "测试世界"
}),
user_prompt: Some("测试封面".to_string()),
size: Some("1600*900".to_string()),
})),
)
.await
.expect_err("missing oss should fail");
let payload = read_error_response(response).await;
assert_eq!(
payload["error"]["code"],
Value::String("SERVICE_UNAVAILABLE".to_string())
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("aliyun-oss".to_string())
);
}
#[tokio::test]
async fn cover_upload_rejects_invalid_data_url_before_touching_oss() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/custom-world/cover-upload");
let authenticated = build_authenticated(&state);
let response = upload_custom_world_cover_image(
State(state),
Extension(request_context),
Extension(authenticated),
Ok(Json(CustomWorldCoverUploadRequest {
profile_id: Some("profile_001".to_string()),
world_name: Some("测试世界".to_string()),
image_data_url: "not-a-data-url".to_string(),
})),
)
.await
.expect_err("invalid data url should fail");
let payload = read_error_response(response).await;
assert_eq!(
payload["error"]["code"],
Value::String("BAD_REQUEST".to_string())
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("custom-world-ai".to_string())
);
}
#[test]
fn parse_image_data_url_accepts_image_payload() {
let parsed =
parse_image_data_url("data:image/png;base64,aGVsbG8=").expect("data url should parse");
assert_eq!(parsed.mime_type, "image/png");
assert_eq!(parsed.bytes, b"hello".to_vec());
}
}

View File

@@ -28,6 +28,7 @@ mod runtime_save;
mod runtime_settings;
mod runtime_story;
mod session_client;
mod sse;
mod state;
mod story_battles;
mod story_sessions;

View File

@@ -32,10 +32,11 @@ pub async fn get_runtime_snapshot(
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_runtime_snapshot(user_id)
.get_runtime_snapshot_record(user_id)
.await
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -70,8 +71,7 @@ pub async fn put_runtime_snapshot(
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
let record = state
.spacetime_client()
.put_runtime_snapshot(
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
payload.bottom_tab,
@@ -80,7 +80,9 @@ pub async fn put_runtime_snapshot(
updated_at_micros,
)
.await
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -95,10 +97,11 @@ pub async fn delete_runtime_snapshot(
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
state
.spacetime_client()
.delete_runtime_snapshot(user_id)
.delete_runtime_snapshot_record(user_id)
.await
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -116,7 +119,9 @@ pub async fn list_profile_save_archives(
.spacetime_client()
.list_profile_save_archives(user_id)
.await
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -151,7 +156,12 @@ pub async fn resume_profile_save_archive(
.spacetime_client()
.resume_profile_save_archive(user_id, world_key)
.await
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_resume_client_error(error)))?;
.map_err(|error| {
runtime_save_error_response(
&request_context,
map_runtime_save_resume_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
@@ -205,7 +215,8 @@ fn map_runtime_save_client_error(error: SpacetimeClientError) -> AppError {
fn map_runtime_save_resume_client_error(error: SpacetimeClientError) -> AppError {
let (status, provider) = match &error {
SpacetimeClientError::Procedure(message)
if message.contains("world_key 不存在") || message.contains("对应 world_key 不存在") =>
if message.contains("world_key 不存在")
|| message.contains("对应 world_key 不存在") =>
{
(StatusCode::NOT_FOUND, "runtime-save")
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
use axum::{
http::{HeaderName, StatusCode, header},
response::{IntoResponse, Response},
};
use serde::Serialize;
use serde_json::json;
use crate::http_error::AppError;
/// 最小缓冲式 SSE builder适用于“先完成业务再一次性返回完整 SSE 文本”的兼容链路。
#[derive(Default)]
pub struct SseEventBuffer {
body: String,
}
impl SseEventBuffer {
pub fn new() -> Self {
Self::default()
}
pub fn push_json<T>(&mut self, event: &str, payload: &T) -> Result<(), AppError>
where
T: Serialize,
{
encode_sse_event(&mut self.body, event, payload)
}
pub fn into_response(self) -> Response {
build_sse_response(self.body)
}
}
pub fn encode_sse_event<T>(body: &mut String, event: &str, payload: &T) -> Result<(), AppError>
where
T: Serialize,
{
let payload_text = serde_json::to_string(payload).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "sse",
"message": format!("SSE payload 序列化失败:{error}"),
}))
})?;
body.push_str("event: ");
body.push_str(event);
body.push('\n');
body.push_str("data: ");
body.push_str(&payload_text);
body.push_str("\n\n");
Ok(())
}
pub fn build_sse_response(body: String) -> Response {
(
[
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
// 反向代理场景下显式关闭缓冲,避免 SSE 事件被聚合后才下发。
(HeaderName::from_static("x-accel-buffering"), "no"),
],
body,
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::{SseEventBuffer, build_sse_response, encode_sse_event};
use axum::body::to_bytes;
use serde_json::json;
#[tokio::test]
async fn encode_sse_event_writes_standard_format() {
let mut body = String::new();
encode_sse_event(&mut body, "reply_delta", &json!({ "text": "hello" }))
.expect("encoding should succeed");
assert_eq!(body, "event: reply_delta\ndata: {\"text\":\"hello\"}\n\n");
}
#[tokio::test]
async fn build_sse_response_sets_standard_headers() {
let response = build_sse_response("event: done\ndata: {\"ok\":true}\n\n".to_string());
assert_eq!(
response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("text/event-stream; charset=utf-8")
);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-cache")
);
assert_eq!(
response
.headers()
.get(HeaderName::from_static("x-accel-buffering"))
.and_then(|value| value.to_str().ok()),
Some("no")
);
}
#[tokio::test]
async fn sse_event_buffer_collects_events_and_returns_response() {
let mut buffer = SseEventBuffer::new();
buffer
.push_json("reply_delta", &json!({ "text": "hello" }))
.expect("first event should encode");
buffer
.push_json("done", &json!({ "ok": true }))
.expect("second event should encode");
let response = buffer.into_response();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body should read");
let text = String::from_utf8(body.to_vec()).expect("body should be utf8");
assert_eq!(
text,
"event: reply_delta\ndata: {\"text\":\"hello\"}\n\nevent: done\ndata: {\"ok\":true}\n\n"
);
}
}

View File

@@ -1,16 +1,26 @@
use std::{error::Error, fmt};
#[cfg(test)]
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use module_ai::{AiTaskService, InMemoryAiTaskStore};
use module_auth::{
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
RefreshSessionService, WechatAuthService, WechatAuthStateService,
};
use module_runtime::RuntimeSnapshotRecord;
#[cfg(test)]
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_auth::{
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
};
use platform_llm::{LlmClient, LlmConfig, LlmError};
use platform_oss::{OssClient, OssConfig, OssError};
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig};
use serde_json::Value;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use crate::config::AppConfig;
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
@@ -35,6 +45,9 @@ pub struct AppState {
ai_task_service: AiTaskService,
spacetime_client: SpacetimeClient,
llm_client: Option<LlmClient>,
#[cfg(test)]
// 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
}
#[derive(Debug)]
@@ -98,6 +111,8 @@ impl AppState {
ai_task_service,
spacetime_client,
llm_client,
#[cfg(test)]
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
})
}
@@ -153,6 +168,162 @@ impl AppState {
pub fn llm_client(&self) -> Option<&LlmClient> {
self.llm_client.as_ref()
}
pub async fn get_runtime_snapshot_record(
&self,
user_id: String,
) -> Result<Option<RuntimeSnapshotRecord>, SpacetimeClientError> {
match self
.spacetime_client
.get_runtime_snapshot(user_id.clone())
.await
{
Ok(record) => {
#[cfg(test)]
if let Some(snapshot) = record.as_ref() {
self.cache_test_runtime_snapshot(snapshot.clone());
}
Ok(record)
}
#[cfg(test)]
Err(_) => Ok(self.read_test_runtime_snapshot(user_id.as_str())),
#[cfg(not(test))]
Err(error) => Err(error),
}
}
pub async fn put_runtime_snapshot_record(
&self,
user_id: String,
saved_at_micros: i64,
bottom_tab: String,
game_state: Value,
current_story: Option<Value>,
updated_at_micros: i64,
) -> Result<RuntimeSnapshotRecord, SpacetimeClientError> {
match self
.spacetime_client
.put_runtime_snapshot(
user_id.clone(),
saved_at_micros,
bottom_tab.clone(),
game_state.clone(),
current_story.clone(),
updated_at_micros,
)
.await
{
Ok(record) => {
#[cfg(test)]
self.cache_test_runtime_snapshot(record.clone());
Ok(record)
}
#[cfg(test)]
Err(_) => {
let snapshot = self.build_test_runtime_snapshot_record(
user_id,
saved_at_micros,
bottom_tab,
game_state,
current_story,
updated_at_micros,
)?;
self.cache_test_runtime_snapshot(snapshot.clone());
Ok(snapshot)
}
#[cfg(not(test))]
Err(error) => Err(error),
}
}
pub async fn delete_runtime_snapshot_record(
&self,
user_id: String,
) -> Result<bool, SpacetimeClientError> {
match self
.spacetime_client
.delete_runtime_snapshot(user_id.clone())
.await
{
Ok(deleted) => {
#[cfg(test)]
if deleted {
self.remove_test_runtime_snapshot(user_id.as_str());
}
Ok(deleted)
}
#[cfg(test)]
Err(_) => Ok(self
.remove_test_runtime_snapshot(user_id.as_str())
.is_some()),
#[cfg(not(test))]
Err(error) => Err(error),
}
}
}
#[cfg(test)]
impl AppState {
fn cache_test_runtime_snapshot(&self, record: RuntimeSnapshotRecord) {
self.test_runtime_snapshot_store
.lock()
.expect("test runtime snapshot store should lock")
.insert(record.user_id.clone(), record);
}
fn read_test_runtime_snapshot(&self, user_id: &str) -> Option<RuntimeSnapshotRecord> {
self.test_runtime_snapshot_store
.lock()
.expect("test runtime snapshot store should lock")
.get(user_id)
.cloned()
}
fn remove_test_runtime_snapshot(&self, user_id: &str) -> Option<RuntimeSnapshotRecord> {
self.test_runtime_snapshot_store
.lock()
.expect("test runtime snapshot store should lock")
.remove(user_id)
}
fn build_test_runtime_snapshot_record(
&self,
user_id: String,
saved_at_micros: i64,
bottom_tab: String,
game_state: Value,
current_story: Option<Value>,
updated_at_micros: i64,
) -> Result<RuntimeSnapshotRecord, SpacetimeClientError> {
let previous = self.read_test_runtime_snapshot(user_id.as_str());
let game_state_json = serde_json::to_string(&game_state).map_err(|error| {
SpacetimeClientError::Runtime(format!("测试快照 game_state 序列化失败: {error}"))
})?;
let current_story_json = current_story
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|error| {
SpacetimeClientError::Runtime(format!("测试快照 current_story 序列化失败: {error}"))
})?;
Ok(RuntimeSnapshotRecord {
user_id,
version: SAVE_SNAPSHOT_VERSION,
saved_at: format_utc_micros(saved_at_micros),
saved_at_micros,
bottom_tab,
game_state,
current_story,
game_state_json,
current_story_json,
created_at_micros: previous
.as_ref()
.map(|record| record.created_at_micros)
.unwrap_or(updated_at_micros),
updated_at_micros,
})
}
}
impl fmt::Display for AppStateInitError {

View File

@@ -140,6 +140,7 @@ pub enum CustomWorldFieldError {
MissingProfileId,
MissingSessionId,
MissingOwnerUserId,
MissingAction,
MissingWorldName,
MissingDraftProfileJson,
MissingProfilePayloadJson,
@@ -227,6 +228,61 @@ pub struct CustomWorldGalleryListResult {
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishBlockerSnapshot {
pub blocker_id: String,
pub code: String,
pub message: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishGateSnapshot {
pub profile_id: String,
pub blockers: Vec<CustomWorldPublishBlockerSnapshot>,
pub blocker_count: u32,
pub publish_ready: bool,
pub can_enter_world: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldWorkSummarySnapshot {
pub work_id: String,
pub source_type: String,
pub status: String,
pub title: String,
pub subtitle: String,
pub summary: String,
pub cover_image_src: Option<String>,
pub cover_render_mode: Option<String>,
pub cover_character_image_srcs_json: String,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
pub stage: Option<RpgAgentStage>,
pub stage_label: Option<String>,
pub playable_npc_count: u32,
pub landmark_count: u32,
pub role_visual_ready_count: Option<u32>,
pub role_animation_ready_count: Option<u32>,
pub role_asset_summary_label: Option<String>,
pub session_id: Option<String>,
pub profile_id: Option<String>,
pub can_resume: bool,
pub can_enter_world: bool,
pub blocker_count: u32,
pub publish_ready: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldWorksListResult {
pub ok: bool,
pub items: Vec<CustomWorldWorkSummarySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentMessageSnapshot {
@@ -273,6 +329,38 @@ pub struct CustomWorldDraftCardSnapshot {
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldDraftCardDetailSectionSnapshot {
pub section_id: String,
pub label: String,
pub value: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldDraftCardDetailSnapshot {
pub card_id: String,
pub kind: RpgAgentDraftCardKind,
pub title: String,
pub sections: Vec<CustomWorldDraftCardDetailSectionSnapshot>,
pub linked_ids_json: String,
pub locked: bool,
pub editable: bool,
pub editable_section_ids_json: String,
pub warning_messages_json: String,
pub asset_status: Option<CustomWorldRoleAssetStatus>,
pub asset_status_label: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldDraftCardDetailResult {
pub ok: bool,
pub card: Option<CustomWorldDraftCardDetailSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentSessionSnapshot {
@@ -290,6 +378,7 @@ pub struct CustomWorldAgentSessionSnapshot {
pub lock_state_json: Option<String>,
pub draft_profile_json: Option<String>,
pub last_assistant_reply: Option<String>,
pub publish_gate_json: Option<String>,
pub result_preview_json: Option<String>,
pub pending_clarifications_json: String,
pub quality_findings_json: String,
@@ -297,6 +386,7 @@ pub struct CustomWorldAgentSessionSnapshot {
pub recommended_replies_json: String,
pub asset_coverage_json: String,
pub checkpoints_json: String,
pub supported_actions_json: String,
pub messages: Vec<CustomWorldAgentMessageSnapshot>,
pub draft_cards: Vec<CustomWorldDraftCardSnapshot>,
pub operations: Vec<CustomWorldAgentOperationSnapshot>,
@@ -425,6 +515,39 @@ pub struct CustomWorldAgentOperationProcedureResult {
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldWorksListInput {
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentCardDetailGetInput {
pub session_id: String,
pub owner_user_id: String,
pub card_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentActionExecuteInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub action: String,
pub payload_json: Option<String>,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentActionExecuteResult {
pub ok: bool,
pub operation: Option<CustomWorldAgentOperationSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldPublishedProfileCompileInput {
@@ -941,6 +1064,52 @@ pub fn validate_custom_world_agent_operation_get_input(
Ok(())
}
pub fn validate_custom_world_works_list_input(
input: &CustomWorldWorksListInput,
) -> Result<(), CustomWorldFieldError> {
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_custom_world_agent_card_detail_get_input(
input: &CustomWorldAgentCardDetailGetInput,
) -> Result<(), CustomWorldFieldError> {
if input.session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.card_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingCardId);
}
Ok(())
}
pub fn validate_custom_world_agent_action_execute_input(
input: &CustomWorldAgentActionExecuteInput,
) -> Result<(), CustomWorldFieldError> {
if input.session_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingSessionId);
}
if input.owner_user_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
if input.operation_id.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOperationId);
}
if input.action.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAction);
}
ensure_optional_json_object(input.payload_json.as_deref())?;
Ok(())
}
pub fn validate_custom_world_agent_message_fields(
message_id: &str,
session_id: &str,
@@ -1290,6 +1459,7 @@ impl fmt::Display for CustomWorldFieldError {
Self::MissingProfileId => f.write_str("custom_world.profile_id 不能为空"),
Self::MissingSessionId => f.write_str("custom_world.session_id 不能为空"),
Self::MissingOwnerUserId => f.write_str("custom_world.owner_user_id 不能为空"),
Self::MissingAction => f.write_str("custom_world_agent_action.action 不能为空"),
Self::MissingWorldName => f.write_str("custom_world.world_name 不能为空"),
Self::MissingDraftProfileJson => {
f.write_str("custom_world.compile.draft_profile_json 不能为空")

View File

@@ -54,8 +54,10 @@
当前阶段新增 Stage5 `runtime story` 兼容桥 DTO 基线:
1. `runtime/story/state/resolve` 请求 DTO
2. `runtime/story/actions/resolve``runtime/story/initial``runtime/story/continue` 请求 DTO
2. `RuntimeStoryActionResponse` 兼容响应 DTO
3. `RuntimeStoryViewModel / presentation / patches / snapshot` 显式结构
4. `RuntimeStoryAiResponse` 兼容响应 DTO
当前仍刻意未做:

View File

@@ -4,7 +4,8 @@ use serde_json::Value;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStorySnapshotPayload {
pub saved_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub saved_at: Option<String>,
pub bottom_tab: String,
pub game_state: Value,
#[serde(default)]
@@ -21,6 +22,72 @@ pub struct RuntimeStoryStateResolveRequest {
pub snapshot: Option<RuntimeStorySnapshotPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryChoiceAction {
#[serde(rename = "type")]
pub action_type: String,
pub function_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload: Option<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryActionRequest {
pub session_id: String,
#[serde(default)]
pub client_version: Option<u32>,
pub action: RuntimeStoryChoiceAction,
#[serde(default)]
pub snapshot: Option<RuntimeStorySnapshotPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryAiRequestOptions {
#[serde(default)]
pub available_options: Vec<Value>,
#[serde(default)]
pub option_catalog: Vec<Value>,
}
impl Default for RuntimeStoryAiRequestOptions {
fn default() -> Self {
Self {
available_options: Vec::new(),
option_catalog: Vec::new(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryAiRequest {
pub world_type: String,
pub character: Value,
#[serde(default)]
pub monsters: Vec<Value>,
#[serde(default)]
pub history: Vec<Value>,
#[serde(default)]
pub choice: String,
pub context: Value,
#[serde(default)]
pub request_options: RuntimeStoryAiRequestOptions,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryAiResponse {
pub story_text: String,
pub options: Vec<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encounter: Option<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryOptionView {
@@ -197,28 +264,70 @@ mod tests {
use serde_json::json;
#[test]
fn runtime_story_state_resolve_request_uses_camel_case_fields() {
let payload = serde_json::to_value(RuntimeStoryStateResolveRequest {
fn runtime_story_state_resolve_request_accepts_missing_saved_at() {
let payload: RuntimeStoryStateResolveRequest = serde_json::from_value(json!({
"sessionId": "runtime-main",
"clientVersion": 7,
"snapshot": {
"bottomTab": "adventure",
"gameState": { "runtimeSessionId": "runtime-main" },
"currentStory": { "text": "营地里的火光还没有熄灭。" }
}
}))
.expect("payload should deserialize");
assert_eq!(payload.session_id, "runtime-main");
assert_eq!(payload.client_version, Some(7));
assert_eq!(
payload.snapshot.expect("snapshot should exist").saved_at,
None
);
}
#[test]
fn runtime_story_action_request_uses_camel_case_fields() {
let payload = serde_json::to_value(RuntimeStoryActionRequest {
session_id: "runtime-main".to_string(),
client_version: Some(7),
client_version: Some(8),
action: RuntimeStoryChoiceAction {
action_type: "story_choice".to_string(),
function_id: "npc_chat".to_string(),
target_id: Some("npc_camp_firekeeper".to_string()),
payload: Some(json!({ "optionText": "继续交谈" })),
},
snapshot: Some(RuntimeStorySnapshotPayload {
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
saved_at: Some("2026-04-22T12:00:00.000Z".to_string()),
bottom_tab: "adventure".to_string(),
game_state: json!({ "runtimeSessionId": "runtime-main" }),
current_story: Some(json!({ "text": "营地里的火光还没有熄灭。" })),
current_story: None,
}),
})
.expect("payload should serialize");
assert_eq!(payload["sessionId"], json!("runtime-main"));
assert_eq!(payload["clientVersion"], json!(7));
assert_eq!(payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z"));
assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure"));
assert_eq!(payload["snapshot"]["gameState"]["runtimeSessionId"], json!("runtime-main"));
assert_eq!(payload["clientVersion"], json!(8));
assert_eq!(payload["action"]["type"], json!("story_choice"));
assert_eq!(payload["action"]["functionId"], json!("npc_chat"));
assert_eq!(
payload["snapshot"]["currentStory"]["text"],
json!("营地里的火光还没有熄灭。")
payload["action"]["targetId"],
json!("npc_camp_firekeeper")
);
assert_eq!(payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z"));
}
#[test]
fn runtime_story_ai_request_defaults_optional_arrays() {
let payload: RuntimeStoryAiRequest = serde_json::from_value(json!({
"worldType": "martial",
"character": { "name": "林迟" },
"context": { "scene": "camp" }
}))
.expect("payload should deserialize");
assert_eq!(payload.world_type, "martial");
assert!(payload.monsters.is_empty());
assert!(payload.history.is_empty());
assert!(payload.request_options.available_options.is_empty());
}
#[test]
@@ -297,7 +406,7 @@ mod tests {
current_npc_battle_outcome: None,
}],
snapshot: RuntimeStorySnapshotPayload {
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
saved_at: Some("2026-04-22T12:00:00.000Z".to_string()),
bottom_tab: "adventure".to_string(),
game_state: json!({ "runtimeSessionId": "runtime-main" }),
current_story: Some(json!({