use axum::{ Json, extract::{Extension, Path, State}, http::StatusCode, response::Response, }; use serde_json::{Value, json}; use shared_contracts::story::{ BeginStorySessionRequest, ContinueStoryRequest, StoryEventPayload, StorySessionMutationResponse, StorySessionPayload, StorySessionStateResponse, }; use spacetime_client::SpacetimeClientError; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, state::AppState, }; pub async fn begin_story_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, Response> { let now_micros = current_utc_micros(); let actor_user_id = authenticated.claims().user_id().to_string(); let result = state .spacetime_client() .begin_story_session( module_story::generate_story_session_id(now_micros), payload.runtime_session_id, actor_user_id, payload.world_profile_id, payload.initial_prompt, payload.opening_summary, now_micros, ) .await .map_err(|error| { story_sessions_error_response(&request_context, map_story_session_client_error(error)) })?; 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, }, }, )) } pub async fn continue_story( State(state): State, Extension(request_context): Extension, Extension(_authenticated): Extension, Json(payload): Json, ) -> Result, Response> { let now_micros = current_utc_micros(); let result = state .spacetime_client() .continue_story( payload.story_session_id, module_story::generate_story_event_id(now_micros), payload.narrative_text, payload.choice_function_id, now_micros, ) .await .map_err(|error| { story_sessions_error_response(&request_context, map_story_session_client_error(error)) })?; 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, }, }, )) } pub async fn get_story_session_state( State(state): State, Path(story_session_id): Path, Extension(request_context): Extension, Extension(_authenticated): Extension, ) -> Result, Response> { let result = state .spacetime_client() .get_story_session_state(story_session_id) .await .map_err(|error| { story_sessions_error_response(&request_context, map_story_session_client_error(error)) })?; 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_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, }) .collect(), }, )) } fn map_story_session_client_error(error: SpacetimeClientError) -> AppError { let status = match &error { SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, _ => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn story_sessions_error_response(request_context: &RequestContext, error: AppError) -> Response { // story session 路由需要保留 request_context,确保错误 envelope 与 requestId 一致。 error.into_response_with_context(Some(request_context)) } fn current_utc_micros() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock should be after unix epoch"); i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") } #[cfg(test)] mod tests { use axum::{ body::Body, http::{Request, StatusCode}, }; use http_body_util::BodyExt; use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; use serde_json::{Value, json}; use time::OffsetDateTime; use tower::ServiceExt; use crate::{app::build_router, config::AppConfig, state::AppState}; #[tokio::test] async fn begin_story_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") .header("content-type", "application/json") .body(Body::from( json!({ "runtimeSessionId": "runtime_001", "worldProfileId": "profile_001", "initialPrompt": "进入营地", "openingSummary": "营地开场" }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn begin_story_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") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "runtimeSessionId": "runtime_001", "worldProfileId": "profile_001", "initialPrompt": "进入营地", "openingSummary": "营地开场" }) .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; let token = issue_access_token(&state); let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/story/sessions/continue") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "storySessionId": "storysess_001", "narrativeText": "你看见篝火边有人招手。", "choiceFunctionId": "talk_to_npc" }) .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 get_story_session_state_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/story/sessions/storysess_001/state") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn get_story_session_state_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("GET") .uri("/api/story/sessions/storysess_001/state") .header("authorization", format!("Bearer {token}")) .header("x-genarrative-response-envelope", "v1") .body(Body::empty()) .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()) ); } async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state .seed_test_phone_user_with_password("13800138108", "secret123") .await .id; state } fn issue_access_token(state: &AppState) -> String { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), session_id: "sess_story_sessions".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("故事会话用户".to_string()), }, state.auth_jwt_config(), OffsetDateTime::now_utc(), ) .expect("claims should build"); sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") } }