后端重写提交
This commit is contained in:
416
server-rs/crates/api-server/src/story_sessions.rs
Normal file
416
server-rs/crates/api-server/src/story_sessions.rs
Normal file
@@ -0,0 +1,416 @@
|
||||
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
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "story_sessions_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
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: 1,
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user