Files
Genarrative/server-rs/crates/api-server/src/story_sessions.rs

413 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<BeginStorySessionRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ContinueStoryRequest>,
) -> Result<Json<Value>, 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<AppState>,
Path(story_session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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")
}
}