use axum::{ Json, extract::{Extension, Path, State}, http::StatusCode, response::Response, }; use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::runtime::{ BasicOkResponse, ProfileSaveArchiveListResponse, ProfileSaveArchiveResumeResponse, ProfileSaveArchiveSummaryResponse, PutSavedGameSnapshotRequest, SavedGameSnapshotResponse, }; 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, request_context::RequestContext, state::AppState, }; #[derive(Clone, Debug, Deserialize)] pub struct WorldKeyPath { #[serde(rename = "world_key")] pub world_key: String, } pub async fn get_runtime_snapshot( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let record = state .get_runtime_snapshot_record(user_id) .await .map_err(|error| { runtime_save_error_response(&request_context, map_runtime_save_client_error(error)) })?; Ok(json_success_body( Some(&request_context), record.as_ref().map(build_saved_game_snapshot_response), )) } pub async fn put_runtime_snapshot( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let now = OffsetDateTime::now_utc(); let saved_at = payload .saved_at .as_deref() .map(parse_rfc3339) .transpose() .map_err(|error| { runtime_save_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-save", "message": format!("savedAt 非法: {error}"), })), ) })? .unwrap_or(now); let updated_at_micros = offset_datetime_to_unix_micros(now); let saved_at_micros = offset_datetime_to_unix_micros(saved_at); let record = state .put_runtime_snapshot_record( user_id, saved_at_micros, payload.bottom_tab, payload.game_state, payload.current_story, updated_at_micros, ) .await .map_err(|error| { runtime_save_error_response(&request_context, map_runtime_save_client_error(error)) })?; Ok(json_success_body( Some(&request_context), build_saved_game_snapshot_response(&record), )) } pub async fn delete_runtime_snapshot( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); state .delete_runtime_snapshot_record(user_id) .await .map_err(|error| { runtime_save_error_response(&request_context, map_runtime_save_client_error(error)) })?; Ok(json_success_body( Some(&request_context), BasicOkResponse { ok: true }, )) } pub async fn list_profile_save_archives( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let entries = state .spacetime_client() .list_profile_save_archives(user_id) .await .map_err(|error| { runtime_save_error_response(&request_context, map_runtime_save_client_error(error)) })?; Ok(json_success_body( Some(&request_context), ProfileSaveArchiveListResponse { entries: entries .iter() .map(build_profile_save_archive_summary_response) .collect(), }, )) } pub async fn resume_profile_save_archive( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Path(path): Path, ) -> Result, Response> { let world_key = path.world_key.trim().to_string(); if world_key.is_empty() { return Err(runtime_save_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-save", "message": "worldKey 不能为空", })), )); } let user_id = authenticated.claims().user_id().to_string(); let (entry, snapshot) = state .spacetime_client() .resume_profile_save_archive(user_id, world_key) .await .map_err(|error| { runtime_save_error_response( &request_context, map_runtime_save_resume_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), ProfileSaveArchiveResumeResponse { entry: build_profile_save_archive_summary_response(&entry), snapshot: build_saved_game_snapshot_response(&snapshot), }, )) } fn build_saved_game_snapshot_response( record: &module_runtime::RuntimeSnapshotRecord, ) -> SavedGameSnapshotResponse { SavedGameSnapshotResponse { version: record.version, saved_at: record.saved_at.clone(), game_state: record.game_state.clone(), bottom_tab: record.bottom_tab.clone(), current_story: record.current_story.clone(), } } fn build_profile_save_archive_summary_response( record: &module_runtime::RuntimeProfileSaveArchiveRecord, ) -> ProfileSaveArchiveSummaryResponse { ProfileSaveArchiveSummaryResponse { world_key: record.world_key.clone(), owner_user_id: record.owner_user_id.clone(), profile_id: record.profile_id.clone(), world_type: record.world_type.clone(), world_name: record.world_name.clone(), subtitle: record.subtitle.clone(), summary_text: record.summary_text.clone(), cover_image_src: record.cover_image_src.clone(), last_played_at: record.saved_at.clone(), } } fn map_runtime_save_client_error(error: SpacetimeClientError) -> AppError { let (status, provider) = match error { SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-save"), _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), }; AppError::from_status(status).with_details(json!({ "provider": provider, "message": error.to_string(), })) } fn map_runtime_save_resume_client_error(error: SpacetimeClientError) -> AppError { let (status, provider) = match &error { SpacetimeClientError::Procedure(message) if message.contains("world_key 不存在") || message.contains("对应 world_key 不存在") => { (StatusCode::NOT_FOUND, "runtime-save") } SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-save"), _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), }; AppError::from_status(status).with_details(json!({ "provider": provider, "message": error.to_string(), })) } fn runtime_save_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } #[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; use time::OffsetDateTime; use tower::ServiceExt; use crate::{app::build_router, config::AppConfig, state::AppState}; #[tokio::test] async fn runtime_snapshot_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/runtime/save/snapshot") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_save_archives_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/runtime/profile/save-archives") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_save_archives_compat_route_matches_main_route_error_shape() { assert_compat_route_matches_main_route_error_shape( "/api/runtime/profile/save-archives", "/api/profile/save-archives", ) .await; } #[tokio::test] async fn resume_profile_save_archive_rejects_blank_world_key() { 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/runtime/profile/save-archives/%20%20") .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_REQUEST); } async fn assert_compat_route_matches_main_route_error_shape( main_route: &str, compat_route: &str, ) { let state = seed_authenticated_state().await; let token = issue_access_token(&state); let app = build_router(state); let main_response = app .clone() .oneshot( Request::builder() .method("GET") .uri(main_route) .header("authorization", format!("Bearer {token}")) .header("x-genarrative-response-envelope", "v1") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); let compat_response = app .oneshot( Request::builder() .method("GET") .uri(compat_route) .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!(main_response.status(), compat_response.status()); let main_payload: Value = serde_json::from_slice( &main_response .into_body() .collect() .await .expect("body should collect") .to_bytes(), ) .expect("response body should be valid json"); let compat_payload: Value = serde_json::from_slice( &compat_response .into_body() .collect() .await .expect("body should collect") .to_bytes(), ) .expect("response body should be valid json"); assert_eq!( main_payload["error"]["details"]["provider"], compat_payload["error"]["details"]["provider"] ); } async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state .seed_test_phone_user_with_password("13800138105", "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_runtime_save".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") } }