Close DDD refactor and remove generated asset proxy

This commit is contained in:
kdletters
2026-05-02 00:27:22 +08:00
parent fd08262bf0
commit 9d9913095d
605 changed files with 11811 additions and 10106 deletions

View File

@@ -67,12 +67,6 @@ use crate::{
},
error_middleware::normalize_error_response,
health::health_check,
legacy_generated_assets::{
proxy_generated_animations, proxy_generated_big_fish_assets,
proxy_generated_character_drafts, proxy_generated_characters,
proxy_generated_custom_world_covers, proxy_generated_custom_world_scenes,
proxy_generated_puzzle_assets, proxy_generated_qwen_sprites,
},
llm::proxy_llm_chat_completions,
login_options::auth_login_options,
logout::logout,
@@ -81,12 +75,11 @@ use crate::{
password_management::{change_password, reset_password},
phone_auth::{phone_login, send_phone_code},
puzzle::{
advance_local_puzzle_next_level, advance_puzzle_next_level, create_puzzle_agent_session,
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message,
submit_puzzle_leaderboard, swap_puzzle_pieces,
advance_puzzle_next_level, create_puzzle_agent_session, delete_puzzle_work,
drag_puzzle_piece_or_group, execute_puzzle_agent_action, get_puzzle_agent_session,
get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works,
list_puzzle_gallery, put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message,
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
},
refresh_session::refresh_session,
request_context::{attach_request_context, resolve_request_id},
@@ -117,7 +110,8 @@ use crate::{
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
},
story_sessions::{
begin_story_session, continue_story, get_story_runtime_projection, get_story_session_state,
begin_story_runtime_session, begin_story_session, continue_story,
get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action,
},
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
};
@@ -193,38 +187,6 @@ pub fn build_router(state: AppState) -> Router {
"/api/auth/public-users/by-id/{user_id}",
get(get_public_user_by_id),
)
.route(
"/generated-character-drafts/{*path}",
get(proxy_generated_character_drafts),
)
.route(
"/generated-characters/{*path}",
get(proxy_generated_characters),
)
.route(
"/generated-animations/{*path}",
get(proxy_generated_animations),
)
.route(
"/generated-big-fish-assets/{*path}",
get(proxy_generated_big_fish_assets),
)
.route(
"/generated-puzzle-assets/{*path}",
get(proxy_generated_puzzle_assets),
)
.route(
"/generated-custom-world-scenes/{*path}",
get(proxy_generated_custom_world_scenes),
)
.route(
"/generated-custom-world-covers/{*path}",
get(proxy_generated_custom_world_covers),
)
.route(
"/generated-qwen-sprites/{*path}",
get(proxy_generated_qwen_sprites),
)
.route(
"/api/auth/me",
get(auth_me).route_layer(middleware::from_fn_with_state(
@@ -738,13 +700,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/local-next-level",
post(advance_local_puzzle_next_level).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}",
get(get_puzzle_run).route_layer(middleware::from_fn_with_state(
@@ -787,13 +742,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/custom-world/entity",
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/entity",
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
@@ -801,13 +749,6 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/custom-world/scene-npc",
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(
@@ -816,19 +757,12 @@ pub fn build_router(state: AppState) -> Router {
)),
)
.route(
"/api/custom-world/scene-image",
"/api/runtime/custom-world/scene-image",
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,
)),
)
.route(
"/api/runtime/custom-world/cover-image",
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
@@ -836,13 +770,6 @@ pub fn build_router(state: AppState) -> Router {
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,
)),
)
.route(
"/api/runtime/custom-world/cover-upload",
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
@@ -944,6 +871,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/runtime",
post(begin_story_runtime_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/{story_session_id}/state",
get(get_story_session_state).route_layer(middleware::from_fn_with_state(
@@ -958,6 +892,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/{story_session_id}/actions/resolve",
post(resolve_story_runtime_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/continue",
post(continue_story).route_layer(middleware::from_fn_with_state(
@@ -1263,6 +1204,85 @@ mod tests {
}
}
#[tokio::test]
async fn deleted_old_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
// 中文注释:旧 custom-world 非 runtime 前缀没有任何新路由可匹配,
// 因此必须稳定返回 404避免前端继续误用旧入口。
for uri in [
"/api/custom-world/entity",
"/api/custom-world/scene-npc",
"/api/custom-world/scene-image",
"/api/custom-world/cover-image",
"/api/custom-world/cover-upload",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("deleted old route request should build"),
)
.await
.expect("deleted old route request should be handled");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/puzzle/runs/local-next-level")
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("deleted old puzzle route request should build"),
)
.await
.expect("deleted old puzzle route request should be handled");
// 中文注释:该路径会被现有 GET /runs/{run_id} 的动态段识别,
// 但 POST 方法没有挂载,返回 405 代表旧 local-next-level handler 已移除。
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn generated_asset_read_proxy_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
// 中文注释:生成资产仍可作为 legacyPublicPath 传给 /api/assets/read-url
// 但不能再通过 /generated-* 同源路由裸读 OSS 对象。
for uri in [
"/generated-character-drafts/hero/visual/candidate.png",
"/generated-characters/hero/visual/master.png",
"/generated-animations/hero/idle/frame01.png",
"/generated-big-fish-assets/session-1/level/image.png",
"/generated-puzzle-assets/session-1/candidate/image.png",
"/generated-custom-world-scenes/world-1/camp/scene.png",
"/generated-custom-world-covers/world-1/cover.webp",
"/generated-qwen-sprites/master/candidate-01.png",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(uri)
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("generated asset proxy route request should build"),
)
.await
.expect("generated asset proxy route request should be handled");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
#[tokio::test]
async fn internal_auth_claims_rejects_missing_bearer_token() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));

View File

@@ -442,6 +442,8 @@ pub async fn generate_custom_world_scene_image(
let owner_user_id = authenticated.claims().user_id().to_string();
let normalized = normalize_scene_image_request(payload)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
require_dashscope_settings(&state)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let asset_id = format!("custom-scene-{}", current_utc_millis());
let asset = execute_billable_asset_operation(
&state,
@@ -703,6 +705,8 @@ pub async fn generate_custom_world_cover_image(
trim_to_option(payload.profile.name.as_deref()).unwrap_or_else(|| "world".to_string());
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
let size = trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1600*900".to_string());
require_dashscope_settings(&state)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let asset_id = format!("custom-cover-{}", current_utc_millis());
let asset = execute_billable_asset_operation(
&state,
@@ -2440,10 +2444,16 @@ mod tests {
serde_json::from_slice(&body).expect("body should be valid json")
}
fn build_state_without_dashscope_key() -> AppState {
let mut config = AppConfig::default();
config.dashscope_api_key = None;
AppState::new(config).expect("state should build")
}
#[tokio::test]
async fn scene_image_returns_service_unavailable_when_dashscope_missing() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/custom-world/scene-image");
let state = build_state_without_dashscope_key();
let request_context = build_request_context("POST /api/runtime/custom-world/scene-image");
let authenticated = build_authenticated(&state);
let response = generate_custom_world_scene_image(
@@ -2512,15 +2522,27 @@ mod tests {
};
let normalized = normalize_scene_image_request(payload).expect("payload should normalize");
let manual_prompt = build_custom_world_scene_image_prompt(SceneImagePromptParams {
profile: SceneImagePromptProfile {
name: "雾海群岛",
subtitle: "失落航线",
tone: "潮湿、神秘、低魔奇幻",
player_goal: "找到王冠并阻止海妖复苏",
summary: "玩家在雾海中追查沉没王冠。",
setting_text: "群岛被永恒雾潮包围。",
},
landmark: SceneImagePromptLandmark {
name: "礁石神殿",
description: "古老礁石上的半沉神殿。",
},
user_prompt: "破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。",
has_reference_image: false,
fallback_landmark_name: Some("礁石神殿"),
fallback_world_name: "雾海群岛",
});
assert!(normalized.prompt.contains("世界名:雾海群岛"));
assert!(normalized.prompt.contains("世界副标题:失落航线"));
assert!(normalized.prompt.contains("场景名称:礁石神殿"));
assert!(
normalized
.prompt
.contains("本次想要生成的画面内容:破碎神殿")
);
assert_eq!(normalized.prompt, manual_prompt);
assert!(normalized.prompt.contains("破碎神殿矗立在蓝绿色雾潮中"));
assert_ne!(
normalized.prompt,
"破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。"
@@ -2585,8 +2607,8 @@ mod tests {
#[tokio::test]
async fn cover_image_returns_service_unavailable_when_dashscope_missing() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/custom-world/cover-image");
let state = build_state_without_dashscope_key();
let request_context = build_request_context("POST /api/runtime/custom-world/cover-image");
let authenticated = build_authenticated(&state);
let response = generate_custom_world_cover_image(
@@ -2622,7 +2644,7 @@ mod tests {
#[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 request_context = build_request_context("POST /api/runtime/custom-world/cover-upload");
let authenticated = build_authenticated(&state);
let response = upload_custom_world_cover_image(

View File

@@ -1,246 +0,0 @@
use axum::{
extract::{Path, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
use serde_json::json;
use crate::{http_error::AppError, platform_errors::map_oss_error, state::AppState};
const CACHE_CONTROL_VALUE: &str = "private, max-age=60";
const ASSET_OBJECT_KEY_HEADER: &str = "x-genarrative-asset-object-key";
pub async fn proxy_generated_character_drafts(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CharacterDrafts, path).await
}
pub async fn proxy_generated_characters(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::Characters, path).await
}
pub async fn proxy_generated_animations(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::Animations, path).await
}
pub async fn proxy_generated_big_fish_assets(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::BigFishAssets, path).await
}
pub async fn proxy_generated_puzzle_assets(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::PuzzleAssets, path).await
}
pub async fn proxy_generated_custom_world_scenes(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldScenes, path).await
}
pub async fn proxy_generated_custom_world_covers(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldCovers, path).await
}
pub async fn proxy_generated_qwen_sprites(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::QwenSprites, path).await
}
async fn proxy_legacy_generated_asset(
state: AppState,
prefix: LegacyAssetPrefix,
path: String,
) -> Response {
match read_legacy_generated_asset(&state, prefix, path).await {
Ok(response) => response,
Err(error) => error.into_response(),
}
}
async fn read_legacy_generated_asset(
state: &AppState,
prefix: LegacyAssetPrefix,
path: String,
) -> Result<Response, 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 object_key = build_generated_object_key(prefix, path.as_str())?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: object_key.clone(),
expire_seconds: Some(60),
})
.map_err(map_legacy_generated_oss_error)?;
let upstream_response = reqwest::Client::new()
.get(signed.signed_url)
.send()
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取 OSS 旧 generated 资源失败:{error}"),
}))
})?;
if upstream_response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "aliyun-oss",
"objectKey": object_key,
})),
);
}
let status = upstream_response.status();
let content_type = upstream_response
.headers()
.get(header::CONTENT_TYPE)
.cloned();
if !status.is_success() {
return Err(map_legacy_generated_upstream_status(status, object_key));
}
let bytes = upstream_response.bytes().await.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取 OSS 旧 generated 资源内容失败:{error}"),
}))
})?;
// 旧 generated 路径会被 <img> / <video> 直接消费,成功分支必须返回原始二进制体。
// 这里显式组装 HeaderMap 并设置长度,避免代理层把已成功读取的 OSS 对象变成空响应。
let mut headers = HeaderMap::new();
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(CACHE_CONTROL_VALUE),
);
headers.insert(
HeaderName::from_static(ASSET_OBJECT_KEY_HEADER),
HeaderValue::from_str(object_key.as_str()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "legacy-generated-assets",
"message": format!("构造资源响应头失败:{error}"),
}))
})?,
);
headers.insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(bytes.len().to_string().as_str()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "legacy-generated-assets",
"message": format!("构造资源长度响应头失败:{error}"),
}))
})?,
);
if let Some(content_type) = content_type {
headers.insert(header::CONTENT_TYPE, content_type);
}
Ok((status, headers, bytes).into_response())
}
fn build_generated_object_key(prefix: LegacyAssetPrefix, path: &str) -> Result<String, AppError> {
let path = path.trim().trim_matches('/');
if path.is_empty() || path.split('/').any(is_invalid_path_segment) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "legacy-generated-assets",
"message": "generated 资源路径不合法。",
})),
);
}
Ok(format!("{}/{}", prefix.as_str(), path))
}
fn is_invalid_path_segment(segment: &str) -> bool {
segment.is_empty() || segment == "." || segment == ".." || segment.contains('\\')
}
fn map_legacy_generated_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}
fn map_legacy_generated_upstream_status(
status: reqwest::StatusCode,
object_key: String,
) -> AppError {
let mapped_status = match status {
reqwest::StatusCode::NOT_FOUND => StatusCode::NOT_FOUND,
reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::UNAUTHORIZED => {
StatusCode::BAD_GATEWAY
}
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(mapped_status).with_details(json!({
"provider": "aliyun-oss",
"objectKey": object_key,
"upstreamStatus": status.as_u16(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_generated_object_key_keeps_supported_prefix() {
let object_key = build_generated_object_key(
LegacyAssetPrefix::Animations,
"hero/animation-set-1/idle/frame01.png",
)
.expect("object key should build");
assert_eq!(
object_key,
"generated-animations/hero/animation-set-1/idle/frame01.png"
);
}
#[test]
fn build_generated_object_key_supports_big_fish_assets() {
let object_key = build_generated_object_key(
LegacyAssetPrefix::BigFishAssets,
"big-fish-session-1/level-main-image/level-1/image.png",
)
.expect("object key should build");
assert_eq!(
object_key,
"generated-big-fish-assets/big-fish-session-1/level-main-image/level-1/image.png"
);
}
#[test]
fn build_generated_object_key_rejects_parent_segment() {
assert!(
build_generated_object_key(LegacyAssetPrefix::Characters, "../secret.png").is_err()
);
}
}

View File

@@ -2,13 +2,17 @@ use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
response::Response,
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use platform_llm::{LlmMessage, LlmMessageRole, LlmTextRequest};
use serde_json::Value;
use serde_json::{Value, json};
use shared_contracts::llm::{
LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole,
};
use std::convert::Infallible;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
@@ -20,15 +24,7 @@ pub async fn proxy_llm_chat_completions(
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<LlmChatCompletionRequest>,
) -> Result<Json<Value>, Response> {
if payload.stream {
return Err(llm_error_response(
&request_context,
AppError::from_status(StatusCode::NOT_IMPLEMENTED)
.with_message("Rust `api-server` 首版暂不支持流式 LLM 代理"),
));
}
) -> Result<Response, Response> {
let llm_client = state.llm_client().ok_or_else(|| {
llm_error_response(
&request_context,
@@ -48,6 +44,10 @@ pub async fn proxy_llm_chat_completions(
enable_web_search: false,
};
if payload.stream {
return Ok(stream_llm_chat_completions(llm_client.clone(), request).into_response());
}
let response = llm_client
.request_text(request)
.await
@@ -61,7 +61,78 @@ pub async fn proxy_llm_chat_completions(
content: response.content,
finish_reason: response.finish_reason,
},
))
)
.into_response())
}
fn stream_llm_chat_completions(
llm_client: platform_llm::LlmClient,
request: LlmTextRequest,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
let stream = async_stream::stream! {
let (delta_tx, mut delta_rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
let llm_stream = llm_client.stream_text(request, move |delta| {
let _ = delta_tx.send(json!({
"delta": delta.delta_text,
"content": delta.accumulated_text,
"finishReason": delta.finish_reason,
}));
});
tokio::pin!(llm_stream);
let llm_result = loop {
// `platform-llm` 负责上游 SSE 解析;这里尽快把增量转成 API 层 SSE 事件。
tokio::select! {
result = &mut llm_stream => break result,
maybe_delta = delta_rx.recv() => {
if let Some(delta) = maybe_delta {
yield Ok::<Event, Infallible>(llm_sse_json_event_or_error("delta", delta));
}
}
}
};
while let Some(delta) = delta_rx.recv().await {
yield Ok::<Event, Infallible>(llm_sse_json_event_or_error("delta", delta));
}
match llm_result {
Ok(response) => {
yield Ok::<Event, Infallible>(llm_sse_json_event_or_error(
"complete",
json!(LlmChatCompletionResponse {
id: response.response_id,
model: response.model,
content: response.content,
finish_reason: response.finish_reason,
}),
));
}
Err(error) => {
let app_error = map_llm_error(error);
yield Ok::<Event, Infallible>(llm_sse_json_event_or_error(
"error",
json!({
"code": app_error.code(),
"message": app_error.message(),
}),
));
}
}
yield Ok::<Event, Infallible>(Event::default().data("[DONE]"));
};
Sse::new(stream)
}
fn llm_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
match serde_json::to_string(&payload) {
Ok(payload_text) => Event::default().event(event_name).data(payload_text),
Err(_) => Event::default()
.event("error")
.data("{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"SSE payload 序列化失败\"}"),
}
}
fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage {
@@ -105,6 +176,7 @@ mod tests {
status_line: &'static str,
content_type: &'static str,
body: String,
extra_headers: Vec<(&'static str, &'static str)>,
}
#[tokio::test]
@@ -113,6 +185,7 @@ mod tests {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"resp_api_server_01","model":"ark-router-test","choices":[{"message":{"content":""},"finish_reason":"stop"}]}"#.to_string(),
extra_headers: Vec::new(),
}]);
let state = seed_authenticated_state(AppConfig {
llm_base_url: server_url,
@@ -176,8 +249,25 @@ mod tests {
}
#[tokio::test]
async fn llm_chat_completions_rejects_stream_mode() {
let state = seed_authenticated_state(AppConfig::default()).await;
async fn llm_chat_completions_streams_sse_payload() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"\"}}]}\n\n",
"data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n"
)
.to_string(),
extra_headers: vec![("x-request-id", "req_llm_stream_01")],
}]);
let state = seed_authenticated_state(AppConfig {
llm_base_url: server_url,
llm_api_key: Some("test-key".to_string()),
..AppConfig::default()
})
.await;
let token = issue_access_token(&state);
let app = build_router(state);
@@ -188,7 +278,6 @@ mod tests {
.uri("/api/llm/chat/completions")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"stream": true,
@@ -203,7 +292,14 @@ mod tests {
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok()),
Some("text/event-stream")
);
let body = response
.into_body()
@@ -211,14 +307,15 @@ mod tests {
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
let body_text = String::from_utf8(body.to_vec()).expect("body should be utf8");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["code"],
Value::String("NOT_IMPLEMENTED".to_string())
);
assert!(body_text.contains("event: delta"));
assert!(body_text.contains(r#""delta":"你""#));
assert!(body_text.contains(r#""content":"你好""#));
assert!(body_text.contains("event: complete"));
assert!(body_text.contains(r#""id":"req_llm_stream_01""#));
assert!(body_text.contains(r#""finishReason":"stop""#));
assert!(body_text.contains("data: [DONE]"));
}
async fn seed_authenticated_state(config: AppConfig) -> AppState {
@@ -306,13 +403,17 @@ mod tests {
fn write_response(stream: &mut std::net::TcpStream, response: MockResponse) {
let body = response.body;
let raw_response = format!(
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
let mut raw_response = format!(
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n",
response.status_line,
response.content_type,
body.len(),
body
body.len()
);
for (name, value) in response.extra_headers {
raw_response.push_str(format!("{name}: {value}\r\n").as_str());
}
raw_response.push_str("\r\n");
raw_response.push_str(body.as_str());
stream
.write_all(raw_response.as_bytes())

View File

@@ -34,7 +34,6 @@ mod custom_world_rpg_draft_prompts;
mod error_middleware;
mod health;
mod http_error;
mod legacy_generated_assets;
mod llm;
mod login_options;
mod logout;

View File

@@ -17,7 +17,7 @@ use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate};
use module_puzzle::PuzzleGeneratedImageCandidate;
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
@@ -36,11 +36,10 @@ use shared_contracts::{
},
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
puzzle_runtime::{
AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
SwapPuzzlePiecesRequest,
DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse,
PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse,
PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse,
StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest,
},
puzzle_works::{
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
@@ -52,14 +51,13 @@ use spacetime_client::{
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
};
use std::convert::Infallible;
use tokio::time::sleep;
@@ -1100,36 +1098,6 @@ pub async fn advance_puzzle_next_level(
))
}
pub async fn advance_local_puzzle_next_level(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<AdvanceLocalPuzzleNextLevelRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let run = build_local_next_puzzle_run(&state, payload, owner_user_id.as_str())
.await
.map_err(|error| puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error))?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(run),
},
))
}
pub async fn submit_puzzle_leaderboard(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
@@ -1385,98 +1353,6 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
}
}
fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRecord {
PuzzleRunRecord {
run_id: run.run_id,
entry_profile_id: run.entry_profile_id,
cleared_level_count: run.cleared_level_count,
current_level_index: run.current_level_index,
current_grid_size: run.current_grid_size,
played_profile_ids: run.played_profile_ids,
previous_level_tags: run.previous_level_tags,
current_level: run.current_level.map(map_puzzle_level_request_record),
recommended_next_profile_id: run.recommended_next_profile_id,
leaderboard_entries: run
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_request_record)
.collect(),
}
}
fn map_puzzle_level_request_record(
level: PuzzleRuntimeLevelSnapshotResponse,
) -> PuzzleRuntimeLevelRecord {
PuzzleRuntimeLevelRecord {
run_id: level.run_id,
level_index: level.level_index,
grid_size: level.grid_size,
profile_id: level.profile_id,
level_name: level.level_name,
author_display_name: level.author_display_name,
theme_tags: level.theme_tags,
cover_image_src: level.cover_image_src,
board: map_puzzle_board_request_record(level.board),
status: level.status,
started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_ms,
leaderboard_entries: level
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_request_record)
.collect(),
}
}
fn map_puzzle_leaderboard_request_record(
entry: PuzzleLeaderboardEntryResponse,
) -> PuzzleLeaderboardEntryRecord {
PuzzleLeaderboardEntryRecord {
rank: entry.rank,
nickname: entry.nickname,
elapsed_ms: entry.elapsed_ms,
is_current_player: entry.is_current_player,
}
}
fn map_puzzle_board_request_record(board: PuzzleBoardSnapshotResponse) -> PuzzleBoardRecord {
PuzzleBoardRecord {
rows: board.rows,
cols: board.cols,
pieces: board
.pieces
.into_iter()
.map(|piece| PuzzlePieceStateRecord {
piece_id: piece.piece_id,
correct_row: piece.correct_row,
correct_col: piece.correct_col,
current_row: piece.current_row,
current_col: piece.current_col,
merged_group_id: piece.merged_group_id,
})
.collect(),
merged_groups: board
.merged_groups
.into_iter()
.map(|group| PuzzleMergedGroupRecord {
group_id: group.group_id,
piece_ids: group.piece_ids,
occupied_cells: group
.occupied_cells
.into_iter()
.map(|cell| PuzzleCellPositionRecord {
row: cell.row,
col: cell.col,
})
.collect(),
})
.collect(),
selected_piece_id: board.selected_piece_id,
all_tiles_resolved: board.all_tiles_resolved,
}
}
fn map_puzzle_runtime_level_response(
level: spacetime_client::PuzzleRuntimeLevelRecord,
) -> PuzzleRuntimeLevelSnapshotResponse {
@@ -1842,343 +1718,6 @@ async fn generate_puzzle_image_candidates(
.collect())
}
async fn build_local_next_puzzle_run(
state: &AppState,
payload: AdvanceLocalPuzzleNextLevelRequest,
owner_user_id: &str,
) -> Result<PuzzleRunRecord, AppError> {
let run = map_puzzle_run_request_record(payload.run);
let current_level = run.current_level.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "currentLevel is required",
}))
})?;
if current_level.status != "cleared" {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "current level is not cleared",
})),
);
}
if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? {
return Ok(build_next_run_from_puzzle_work(run, gallery_item));
}
let source_session_id = payload.source_session_id.unwrap_or_default();
if source_session_id.trim().is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "sourceSessionId is required when gallery has no next puzzle work",
})),
);
}
let session = state
.spacetime_client()
.get_puzzle_agent_session(source_session_id, owner_user_id.to_string())
.await
.map_err(map_puzzle_client_error)?;
if let Some(candidate) = session
.draft
.as_ref()
.and_then(|draft| pick_unused_puzzle_candidate(&draft.candidates, &run.played_profile_ids))
{
return Ok(build_next_run_from_candidate(run, &session, candidate));
}
let draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "puzzle draft is required when gallery has no next puzzle work",
}))
})?;
let candidates = generate_puzzle_image_candidates(
state,
owner_user_id,
&session.session_id,
&draft.level_name,
&draft.summary,
None,
1,
draft.candidates.len(),
)
.await
.map_err(|message| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": message,
}))
})?;
let candidates_json = serde_json::to_string(
&candidates
.iter()
.map(to_puzzle_generated_image_candidate)
.collect::<Vec<_>>(),
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": format!("拼图候选图序列化失败:{error}"),
}))
})?;
let updated_session = state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: session.session_id,
owner_user_id: owner_user_id.to_string(),
candidates_json,
saved_at_micros: current_utc_micros(),
})
.await
.map_err(map_puzzle_client_error)?;
let candidate = updated_session
.draft
.as_ref()
.and_then(|draft| {
draft
.candidates
.iter()
.find(|candidate| !candidate.image_src.is_empty())
})
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": "现场生成后没有可用候选图",
}))
})?;
Ok(build_next_run_from_candidate(
run,
&updated_session,
candidate,
))
}
async fn resolve_gallery_next_puzzle_work(
state: &AppState,
run: &PuzzleRunRecord,
) -> Result<Option<PuzzleWorkProfileRecord>, AppError> {
let items = state
.spacetime_client()
.list_puzzle_gallery()
.await
.map_err(map_puzzle_client_error)?;
Ok(items.into_iter().find(|item| {
item.publication_status == "published"
&& item
.cover_image_src
.as_ref()
.is_some_and(|value| !value.is_empty())
&& !run.played_profile_ids.contains(&item.profile_id)
}))
}
fn pick_unused_puzzle_candidate<'a>(
candidates: &'a [PuzzleGeneratedImageCandidateRecord],
played_profile_ids: &[String],
) -> Option<&'a PuzzleGeneratedImageCandidateRecord> {
candidates.iter().find(|candidate| {
!candidate.image_src.is_empty()
&& !played_profile_ids
.iter()
.any(|profile_id| profile_id.contains(&candidate.candidate_id))
})
}
fn build_next_run_from_puzzle_work(
run: PuzzleRunRecord,
item: PuzzleWorkProfileRecord,
) -> PuzzleRunRecord {
build_next_run_from_parts(
run,
item.profile_id,
item.level_name,
item.author_display_name,
item.theme_tags,
item.cover_image_src,
)
}
fn build_next_run_from_candidate(
run: PuzzleRunRecord,
session: &PuzzleAgentSessionRecord,
candidate: &PuzzleGeneratedImageCandidateRecord,
) -> PuzzleRunRecord {
let draft = session.draft.as_ref();
let level_index = run.current_level_index + 1;
build_next_run_from_parts(
run,
format!(
"{}-{}-level-{}",
session.session_id, candidate.candidate_id, level_index
),
draft
.map(|draft| format!("{} · 候选 {}", draft.level_name, level_index))
.unwrap_or_else(|| format!("候选拼图 {level_index}")),
"当前草稿".to_string(),
draft
.map(|draft| draft.theme_tags.clone())
.unwrap_or_default(),
Some(candidate.image_src.clone()),
)
}
fn build_next_run_from_parts(
run: PuzzleRunRecord,
profile_id: String,
level_name: String,
author_display_name: String,
theme_tags: Vec<String>,
cover_image_src: Option<String>,
) -> PuzzleRunRecord {
let next_level_index = run.current_level_index + 1;
let grid_size = if run.cleared_level_count >= 3 { 4 } else { 3 };
let mut played_profile_ids = run.played_profile_ids.clone();
if !played_profile_ids.contains(&profile_id) {
played_profile_ids.push(profile_id.clone());
}
let board = build_local_puzzle_board(grid_size, &run.run_id, &profile_id, next_level_index);
PuzzleRunRecord {
run_id: run.run_id.clone(),
entry_profile_id: run.entry_profile_id,
cleared_level_count: run.cleared_level_count,
current_level_index: next_level_index,
current_grid_size: grid_size,
played_profile_ids,
previous_level_tags: theme_tags.clone(),
current_level: Some(PuzzleRuntimeLevelRecord {
run_id: run.run_id,
level_index: next_level_index,
grid_size,
profile_id,
level_name,
author_display_name,
theme_tags,
cover_image_src,
board,
status: "playing".to_string(),
started_at_ms: (current_utc_micros().max(0) as u64) / 1_000,
cleared_at_ms: None,
elapsed_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
leaderboard_entries: Vec::new(),
}
}
fn build_local_puzzle_board(
grid_size: u32,
run_id: &str,
profile_id: &str,
level_index: u32,
) -> PuzzleBoardRecord {
let board = module_puzzle::build_initial_board_with_seed(
grid_size,
build_local_puzzle_shuffle_seed(run_id, profile_id, level_index, grid_size),
)
.unwrap_or_else(|_| {
module_puzzle::build_initial_board_with_seed(3, 1)
.expect("fallback puzzle board should use supported grid size")
});
map_puzzle_board_snapshot_record(board)
}
fn build_local_puzzle_shuffle_seed(
run_id: &str,
profile_id: &str,
level_index: u32,
grid_size: u32,
) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in run_id
.bytes()
.chain(profile_id.bytes())
.chain(level_index.to_le_bytes())
.chain(grid_size.to_le_bytes())
{
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn map_puzzle_board_snapshot_record(board: PuzzleBoardSnapshot) -> PuzzleBoardRecord {
PuzzleBoardRecord {
rows: board.rows,
cols: board.cols,
pieces: board
.pieces
.into_iter()
.map(|piece| PuzzlePieceStateRecord {
piece_id: piece.piece_id,
correct_row: piece.correct_row,
correct_col: piece.correct_col,
current_row: piece.current_row,
current_col: piece.current_col,
merged_group_id: piece.merged_group_id,
})
.collect(),
merged_groups: board
.merged_groups
.into_iter()
.map(|group| PuzzleMergedGroupRecord {
group_id: group.group_id,
piece_ids: group.piece_ids,
occupied_cells: group
.occupied_cells
.into_iter()
.map(|cell| PuzzleCellPositionRecord {
row: cell.row,
col: cell.col,
})
.collect(),
})
.collect(),
selected_piece_id: board.selected_piece_id,
all_tiles_resolved: board.all_tiles_resolved,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn board_positions(board: &PuzzleBoardRecord) -> Vec<(u32, u32)> {
board
.pieces
.iter()
.map(|piece| (piece.current_row, piece.current_col))
.collect()
}
fn has_original_neighbor_pair(board: &PuzzleBoardRecord) -> bool {
board.pieces.iter().any(|piece| {
board.pieces.iter().any(|candidate| {
piece.piece_id != candidate.piece_id
&& piece.current_row.abs_diff(candidate.current_row)
+ piece.current_col.abs_diff(candidate.current_col)
== 1
&& piece.correct_row.abs_diff(candidate.correct_row)
+ piece.correct_col.abs_diff(candidate.correct_col)
== 1
})
})
}
#[test]
fn local_next_level_board_shuffle_changes_by_level() {
let second = build_local_puzzle_board(3, "run-a", "profile-level-2", 2);
let third = build_local_puzzle_board(3, "run-a", "profile-level-3", 3);
assert_ne!(board_positions(&second), board_positions(&third));
assert!(!has_original_neighbor_pair(&second));
assert!(!has_original_neighbor_pair(&third));
}
}
struct PuzzleDashScopeSettings {
base_url: String,
api_key: String,

View File

@@ -4,12 +4,17 @@ use axum::{
http::StatusCode,
response::Response,
};
use module_runtime::RuntimeSnapshotRecord;
use serde_json::{Value, json};
use shared_contracts::story::{
BeginStorySessionRequest, ContinueStoryRequest, StoryEventPayload,
StorySessionMutationResponse, StorySessionPayload, StorySessionStateResponse,
BeginStoryRuntimeSessionRequest, BeginStorySessionRequest, ContinueStoryRequest,
ResolveStoryRuntimeActionRequest, StoryEventPayload, StoryRuntimeMutationResponse,
StoryRuntimeSnapshotPayload, StorySessionMutationResponse, StorySessionPayload,
StorySessionStateResponse,
};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
@@ -43,28 +48,75 @@ pub async fn begin_story_session(
Ok(json_success_body(
Some(&request_context),
StorySessionMutationResponse {
story_session: StorySessionPayload {
story_session_id: result.session.story_session_id,
runtime_session_id: result.session.runtime_session_id,
actor_user_id: result.session.actor_user_id,
world_profile_id: result.session.world_profile_id,
initial_prompt: result.session.initial_prompt,
opening_summary: result.session.opening_summary,
latest_narrative_text: result.session.latest_narrative_text,
latest_choice_function_id: result.session.latest_choice_function_id,
status: result.session.status,
version: result.session.version,
created_at: result.session.created_at,
updated_at: result.session.updated_at,
},
story_event: StoryEventPayload {
event_id: result.event.event_id,
story_session_id: result.event.story_session_id,
event_kind: result.event.event_kind,
narrative_text: result.event.narrative_text,
choice_function_id: result.event.choice_function_id,
created_at: result.event.created_at,
},
story_session: story_session_payload_from_record(result.session),
story_event: story_event_payload_from_record(result.event),
},
))
}
pub async fn begin_story_runtime_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<BeginStoryRuntimeSessionRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let runtime_session_id = module_runtime_story::generate_runtime_session_id(
actor_user_id.as_str(),
payload.custom_world_profile.as_ref(),
&payload.character,
now_micros,
);
let story_session_id = module_story::generate_story_session_id(now_micros);
let built = module_runtime_story::build_runtime_story_bootstrap(
&payload,
module_runtime_story::RuntimeStoryBootstrapSeed {
runtime_session_id,
story_session_id,
actor_user_id: actor_user_id.clone(),
now_micros,
},
)
.map_err(|message| {
story_sessions_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": message,
})),
)
})?;
let story_result = state
.spacetime_client()
.begin_story_session(
built.story_session_id.clone(),
built.runtime_session_id.clone(),
actor_user_id.clone(),
built.world_profile_id,
built.initial_prompt,
built.opening_summary,
now_micros,
)
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
let persisted =
persist_story_runtime_snapshot(&state, &request_context, actor_user_id, built.snapshot)
.await?;
Ok(json_success_body(
Some(&request_context),
StoryRuntimeMutationResponse {
projection: build_story_runtime_projection_from_persisted(
story_session_payload_from_record(story_result.session),
vec![story_event_payload_from_record(story_result.event)],
&persisted,
persisted.version,
),
},
))
}
@@ -108,28 +160,105 @@ pub async fn continue_story(
Ok(json_success_body(
Some(&request_context),
StorySessionMutationResponse {
story_session: StorySessionPayload {
story_session_id: result.session.story_session_id,
runtime_session_id: result.session.runtime_session_id,
actor_user_id: result.session.actor_user_id,
world_profile_id: result.session.world_profile_id,
initial_prompt: result.session.initial_prompt,
opening_summary: result.session.opening_summary,
latest_narrative_text: result.session.latest_narrative_text,
latest_choice_function_id: result.session.latest_choice_function_id,
status: result.session.status,
version: result.session.version,
created_at: result.session.created_at,
updated_at: result.session.updated_at,
},
story_event: StoryEventPayload {
event_id: result.event.event_id,
story_session_id: result.event.story_session_id,
event_kind: result.event.event_kind,
narrative_text: result.event.narrative_text,
choice_function_id: result.event.choice_function_id,
created_at: result.event.created_at,
},
story_session: story_session_payload_from_record(result.session),
story_event: story_event_payload_from_record(result.event),
},
))
}
pub async fn resolve_story_runtime_action(
State(state): State<AppState>,
Path(story_session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ResolveStoryRuntimeActionRequest>,
) -> Result<Json<Value>, Response> {
let now_micros = current_utc_micros();
let actor_user_id = authenticated.claims().user_id().to_string();
let story_session_id = validate_story_runtime_action_path(
&request_context,
story_session_id,
payload.story_session_id.as_str(),
)?;
let story_state = state
.spacetime_client()
.get_story_session_state(story_session_id.clone())
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
require_story_session_owner(
&request_context,
&story_state.session.actor_user_id,
&actor_user_id,
)?;
let snapshot_record = state
.get_runtime_snapshot_record(actor_user_id.clone())
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?
.ok_or_else(|| {
story_sessions_error_response(
&request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "story-runtime",
"message": "当前用户缺少 runtime snapshot",
})),
)
})?;
let snapshot = story_runtime_snapshot_payload_from_record(&snapshot_record);
validate_story_runtime_client_version(
&request_context,
payload.client_version,
&snapshot.game_state,
)?;
let resolved = module_runtime_story::resolve_story_runtime_action(
module_runtime_story::StoryRuntimeActionResolveInput {
story_session_id: story_state.session.story_session_id.clone(),
runtime_session_id: story_state.session.runtime_session_id.clone(),
snapshot,
request: payload,
},
)
.map_err(|message| {
story_sessions_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": message,
})),
)
})?;
let story_result = state
.spacetime_client()
.continue_story(
story_state.session.story_session_id,
module_story::generate_story_event_id(now_micros),
resolved.narrative_text,
resolved.choice_function_id,
now_micros,
)
.await
.map_err(|error| {
story_sessions_error_response(&request_context, map_story_session_client_error(error))
})?;
let persisted =
persist_story_runtime_snapshot(&state, &request_context, actor_user_id, resolved.snapshot)
.await?;
Ok(json_success_body(
Some(&request_context),
StoryRuntimeMutationResponse {
projection: build_story_runtime_projection_from_persisted(
story_session_payload_from_record(story_result.session),
vec![story_event_payload_from_record(story_result.event)],
&persisted,
resolved.server_version.max(persisted.version),
),
},
))
}
@@ -157,36 +286,257 @@ pub async fn get_story_session_state(
Ok(json_success_body(
Some(&request_context),
StorySessionStateResponse {
story_session: StorySessionPayload {
story_session_id: result.session.story_session_id,
runtime_session_id: result.session.runtime_session_id,
actor_user_id: result.session.actor_user_id,
world_profile_id: result.session.world_profile_id,
initial_prompt: result.session.initial_prompt,
opening_summary: result.session.opening_summary,
latest_narrative_text: result.session.latest_narrative_text,
latest_choice_function_id: result.session.latest_choice_function_id,
status: result.session.status,
version: result.session.version,
created_at: result.session.created_at,
updated_at: result.session.updated_at,
},
story_session: story_session_payload_from_record(result.session),
story_events: result
.events
.into_iter()
.map(|event| StoryEventPayload {
event_id: event.event_id,
story_session_id: event.story_session_id,
event_kind: event.event_kind,
narrative_text: event.narrative_text,
choice_function_id: event.choice_function_id,
created_at: event.created_at,
})
.map(story_event_payload_from_record)
.collect(),
},
))
}
async fn persist_story_runtime_snapshot(
state: &AppState,
request_context: &RequestContext,
user_id: String,
snapshot: StoryRuntimeSnapshotPayload,
) -> Result<RuntimeSnapshotRecord, Response> {
validate_story_runtime_snapshot_payload(&snapshot).map_err(|message| {
story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": message,
})),
)
})?;
let now = OffsetDateTime::now_utc();
let saved_at = snapshot
.saved_at
.as_deref()
.and_then(module_runtime_story::normalize_required_string)
.map(|value| parse_rfc3339(value.as_str()))
.transpose()
.map_err(|error| {
story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"field": "snapshot.savedAt",
"message": format!("savedAt 非法: {error}"),
})),
)
})?
.unwrap_or(now);
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
let updated_at_micros = offset_datetime_to_unix_micros(now);
let game_state = canonicalize_story_runtime_game_state(snapshot.game_state);
state
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
snapshot.bottom_tab,
game_state,
snapshot.current_story,
updated_at_micros,
)
.await
.map_err(|error| {
story_sessions_error_response(request_context, map_story_session_client_error(error))
})
}
fn canonicalize_story_runtime_game_state(mut game_state: Value) -> Value {
if let Some(root) = game_state.as_object_mut() {
// 中文注释NPC 交易 / 赠礼 view 是展示层投影,持久快照只保存可复算真相。
root.remove("runtimeNpcInteraction");
}
game_state
}
fn validate_story_runtime_snapshot_payload(
snapshot: &StoryRuntimeSnapshotPayload,
) -> Result<(), String> {
if module_runtime_story::normalize_required_string(snapshot.bottom_tab.as_str()).is_none() {
return Err("snapshot.bottomTab 不能为空".to_string());
}
if !snapshot.game_state.is_object() {
return Err("snapshot.gameState 必须是 JSON object".to_string());
}
if snapshot
.current_story
.as_ref()
.is_some_and(|current_story| !current_story.is_object())
{
return Err("snapshot.currentStory 必须是 JSON object 或 null".to_string());
}
Ok(())
}
fn story_runtime_snapshot_payload_from_record(
record: &RuntimeSnapshotRecord,
) -> StoryRuntimeSnapshotPayload {
let mut game_state = record.game_state.clone();
module_runtime_story::write_runtime_npc_interaction_view(&mut game_state);
StoryRuntimeSnapshotPayload {
saved_at: Some(record.saved_at.clone()),
bottom_tab: record.bottom_tab.clone(),
game_state,
current_story: record.current_story.clone(),
}
}
fn build_story_runtime_projection_from_persisted(
story_session: StorySessionPayload,
story_events: Vec<StoryEventPayload>,
record: &RuntimeSnapshotRecord,
server_version: u32,
) -> shared_contracts::story::StoryRuntimeProjectionResponse {
let snapshot = story_runtime_snapshot_payload_from_record(record);
let current_story = snapshot.current_story.as_ref();
let options =
module_runtime_story::build_runtime_story_options(current_story, &snapshot.game_state);
let current_narrative_text = read_story_runtime_current_text(current_story)
.or_else(|| Some(story_session.latest_narrative_text.clone()));
let action_result_text = read_story_runtime_current_field(current_story, "resultText");
let toast = read_story_runtime_current_field(current_story, "toast");
module_runtime_story::build_story_runtime_projection(
module_runtime_story::StoryRuntimeProjectionSource {
story_session,
story_events,
game_state: snapshot.game_state,
options,
server_version,
current_narrative_text,
action_result_text,
toast,
},
)
}
fn read_story_runtime_current_text(current_story: Option<&Value>) -> Option<String> {
read_story_runtime_current_field(current_story, "text")
.or_else(|| read_story_runtime_current_field(current_story, "storyText"))
}
fn read_story_runtime_current_field(current_story: Option<&Value>, field: &str) -> Option<String> {
current_story?
.as_object()?
.get(field)?
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn validate_story_runtime_action_path(
request_context: &RequestContext,
path_story_session_id: String,
body_story_session_id: &str,
) -> Result<String, Response> {
let path_story_session_id =
module_runtime_story::normalize_required_string(path_story_session_id.as_str())
.ok_or_else(|| {
story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": "storySessionId 不能为空",
})),
)
})?;
let body_story_session_id = module_runtime_story::normalize_required_string(
body_story_session_id,
)
.ok_or_else(|| {
story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "story-runtime",
"message": "request.storySessionId 不能为空",
})),
)
})?;
if path_story_session_id != body_story_session_id {
return Err(story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "story-runtime",
"message": "path storySessionId 与 request.storySessionId 不一致",
"pathStorySessionId": path_story_session_id,
"requestStorySessionId": body_story_session_id,
})),
));
}
Ok(path_story_session_id)
}
fn validate_story_runtime_client_version(
request_context: &RequestContext,
client_version: Option<u32>,
game_state: &Value,
) -> Result<(), Response> {
let Some(client_version) = client_version else {
return Ok(());
};
let Some(server_version) =
module_runtime_story::read_u32_field(game_state, "runtimeActionVersion")
else {
return Ok(());
};
if client_version == server_version {
return Ok(());
}
Err(story_sessions_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "story-runtime",
"message": "运行时版本已变化,请先同步最新快照后再提交动作",
"clientVersion": client_version,
"serverVersion": server_version,
})),
))
}
fn story_session_payload_from_record(
record: module_story::StorySessionRecord,
) -> StorySessionPayload {
StorySessionPayload {
story_session_id: record.story_session_id,
runtime_session_id: record.runtime_session_id,
actor_user_id: record.actor_user_id,
world_profile_id: record.world_profile_id,
initial_prompt: record.initial_prompt,
opening_summary: record.opening_summary,
latest_narrative_text: record.latest_narrative_text,
latest_choice_function_id: record.latest_choice_function_id,
status: record.status,
version: record.version,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn story_event_payload_from_record(record: module_story::StoryEventRecord) -> StoryEventPayload {
StoryEventPayload {
event_id: record.event_id,
story_session_id: record.story_session_id,
event_kind: record.event_kind,
narrative_text: record.narrative_text,
choice_function_id: record.choice_function_id,
created_at: record.created_at,
}
}
pub async fn get_story_runtime_projection(
State(state): State<AppState>,
Path(story_session_id): Path<String>,
@@ -373,6 +723,156 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn begin_story_runtime_session_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/sessions/runtime")
.header("content-type", "application/json")
.body(Body::from(
json!({
"worldType": "CUSTOM",
"customWorldProfile": { "id": "profile_001" },
"character": { "id": "hero_001", "name": "沈砺" },
"runtimeMode": "play",
"disablePersistence": false
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn begin_story_runtime_session_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/sessions/runtime")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"worldType": "CUSTOM",
"customWorldProfile": { "id": "profile_001" },
"character": { "id": "hero_001", "name": "沈砺" },
"runtimeMode": "play",
"disablePersistence": false
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[tokio::test]
async fn resolve_story_runtime_action_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/sessions/storysess_001/actions/resolve")
.header("content-type", "application/json")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"clientVersion": 1,
"functionId": "idle_observe_signs",
"actionText": "观察周围迹象",
"payload": { "optionText": "观察周围迹象" }
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn resolve_story_runtime_action_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/story/sessions/storysess_001/actions/resolve")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"storySessionId": "storysess_001",
"clientVersion": 1,
"functionId": "idle_observe_signs",
"actionText": "观察周围迹象",
"payload": { "optionText": "观察周围迹象" }
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[tokio::test]
async fn continue_story_returns_bad_gateway_when_spacetime_not_published() {
let state = seed_authenticated_state().await;