feat: checkpoint m5 and bootstrap m6 asset flow
This commit is contained in:
@@ -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` 真相链已完成。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('>', ">")
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
130
server-rs/crates/api-server/src/sse.rs
Normal file
130
server-rs/crates/api-server/src/sse.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user