use axum::{ Json, extract::{Extension, Path, State}, http::StatusCode, response::Response, }; use module_runtime::format_utc_micros; use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::runtime::{ BasicOkResponse, ProfileSaveArchiveListResponse, ProfileSaveArchiveResumeResponse, ProfileSaveArchiveSummaryResponse, PutRuntimeSaveCheckpointRequest, 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 session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| { runtime_save_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-save", "field": "sessionId", "message": "sessionId 不能为空", })), ) })?; let bottom_tab = normalize_required_string(payload.bottom_tab.as_str()).ok_or_else(|| { runtime_save_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-save", "field": "bottomTab", "message": "bottomTab 不能为空", })), ) })?; 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 existing = state .get_runtime_snapshot_record(user_id.clone()) .await .map_err(|error| { runtime_save_error_response(&request_context, map_runtime_save_client_error(error)) })? .ok_or_else(|| { runtime_save_error_response( &request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "runtime-save", "message": "运行时快照不存在,无法创建后端 checkpoint", })), ) })?; validate_checkpoint_snapshot(&request_context, &session_id, &existing.game_state)?; let game_state = sync_runtime_snapshot_play_time(existing.game_state, updated_at_micros); let record = state .put_runtime_snapshot_record( user_id, saved_at_micros, bottom_tab, game_state, existing.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 is_non_persistent_runtime_snapshot(game_state: &Value) -> bool { let Some(game_state) = game_state.as_object() else { return false; }; if game_state .get("runtimePersistenceDisabled") .and_then(Value::as_bool) .unwrap_or(false) { return true; } matches!( game_state .get("runtimeMode") .and_then(Value::as_str) .map(str::trim), Some("preview") | Some("test") ) } fn validate_checkpoint_snapshot( request_context: &RequestContext, session_id: &str, game_state: &Value, ) -> Result<(), Response> { if is_non_persistent_runtime_snapshot(game_state) { return Err(runtime_save_error_response( request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "runtime-save", "message": "预览或测试运行态不能创建正式 checkpoint", })), )); } let persisted_session_id = read_string_field(game_state, "runtimeSessionId").ok_or_else(|| { runtime_save_error_response( request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "runtime-save", "message": "服务端运行时快照缺少 runtimeSessionId,无法创建 checkpoint", })), ) })?; if persisted_session_id != session_id { return Err(runtime_save_error_response( request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "runtime-save", "message": "checkpoint sessionId 与服务端运行时快照不一致", "expectedSessionId": persisted_session_id, "actualSessionId": session_id, })), )); } Ok(()) } fn sync_runtime_snapshot_play_time(mut game_state: Value, now_micros: i64) -> Value { let Some(game_state_object) = game_state.as_object_mut() else { return game_state; }; let now_text = format_utc_micros(now_micros); let Some(runtime_stats) = game_state_object .get_mut("runtimeStats") .and_then(Value::as_object_mut) else { game_state_object.insert( "runtimeStats".to_string(), json!({ "playTimeMs": 0, "lastPlayTickAt": now_text, "hostileNpcsDefeated": 0, "questsAccepted": 0, "itemsUsed": 0, "scenesTraveled": 0, }), ); return game_state; }; let current_play_time = runtime_stats .get("playTimeMs") .and_then(Value::as_f64) .filter(|value| value.is_finite() && *value >= 0.0) .unwrap_or(0.0); let elapsed_ms = runtime_stats .get("lastPlayTickAt") .and_then(Value::as_str) .and_then(|last_tick| parse_rfc3339(last_tick).ok()) .map(offset_datetime_to_unix_micros) .map(|last_tick_micros| now_micros.saturating_sub(last_tick_micros).max(0) as f64 / 1000.0) .unwrap_or(0.0); let next_play_time = (current_play_time + elapsed_ms).floor().max(0.0); // 中文注释:checkpoint 只刷新服务端已有 runtimeStats 的时间水位, // 不从浏览器接收任何任务、背包、战斗或剧情状态。 runtime_stats.insert("playTimeMs".to_string(), Value::from(next_play_time as i64)); runtime_stats.insert("lastPlayTickAt".to_string(), Value::String(now_text)); game_state } fn read_string_field(value: &Value, field: &str) -> Option { value .as_object()? .get(field)? .as_str() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn normalize_required_string(value: &str) -> Option { let normalized = value.trim(); if normalized.is_empty() { None } else { Some(normalized.to_string()) } } 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, json}; 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 runtime_snapshot_checkpoint_rejects_legacy_full_snapshot_upload() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); let app = build_router(state); let response = app .oneshot( Request::builder() .method("PUT") .uri("/api/runtime/save/snapshot") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": "runtime-main", "bottomTab": "adventure", "gameState": { "runtimeSessionId": "runtime-main" }, "currentStory": null }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); } #[tokio::test] async fn runtime_snapshot_checkpoint_requires_existing_server_snapshot() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); let app = build_router(state); let response = app .oneshot( Request::builder() .method("PUT") .uri("/api/runtime/save/snapshot") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": "runtime-main", "bottomTab": "adventure" }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::CONFLICT); } #[tokio::test] async fn runtime_snapshot_checkpoint_rejects_session_mismatch() { let state = seed_authenticated_state().await; seed_runtime_snapshot(&state, "runtime-server", "adventure").await; let token = issue_access_token(&state); let app = build_router(state); let response = app .oneshot( Request::builder() .method("PUT") .uri("/api/runtime/save/snapshot") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": "runtime-client", "bottomTab": "inventory" }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::CONFLICT); } #[tokio::test] async fn runtime_snapshot_checkpoint_uses_persisted_server_snapshot() { let state = seed_authenticated_state().await; seed_runtime_snapshot(&state, "runtime-main", "adventure").await; let token = issue_access_token(&state); let app = build_router(state); let response = app .oneshot( Request::builder() .method("PUT") .uri("/api/runtime/save/snapshot") .header("authorization", format!("Bearer {token}")) .header("x-genarrative-response-envelope", "v1") .header("content-type", "application/json") .body(Body::from( json!({ "sessionId": "runtime-main", "bottomTab": "inventory" }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let payload: Value = serde_json::from_slice( &response .into_body() .collect() .await .expect("body should collect") .to_bytes(), ) .expect("response body should be valid json"); assert_eq!(payload["data"]["bottomTab"], json!("inventory")); assert_eq!( payload["data"]["gameState"]["runtimeSessionId"], json!("runtime-main") ); assert_eq!( payload["data"]["gameState"]["serverOnlyField"], json!("persisted") ); assert_eq!(payload["data"]["currentStory"]["text"], json!("服务端故事")); assert!( payload["data"]["gameState"]["runtimeStats"]["playTimeMs"] .as_i64() .unwrap_or_default() >= 2000 ); } #[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 } async fn seed_runtime_snapshot(state: &AppState, session_id: &str, bottom_tab: &str) { let now = OffsetDateTime::now_utc(); let now_micros = shared_kernel::offset_datetime_to_unix_micros(now); state .put_runtime_snapshot_record( "user_00000001".to_string(), now_micros - 2_000_000, bottom_tab.to_string(), json!({ "runtimeSessionId": session_id, "runtimeMode": "play", "runtimePersistenceDisabled": false, "currentScene": "Story", "serverOnlyField": "persisted", "runtimeStats": { "playTimeMs": 0, "lastPlayTickAt": module_runtime::format_utc_micros(now_micros - 2_000_000), "hostileNpcsDefeated": 0, "questsAccepted": 0, "itemsUsed": 0, "scenesTraveled": 0 } }), Some(json!({ "text": "服务端故事", "options": [] })), now_micros - 2_000_000, ) .await .expect("runtime snapshot should seed"); } 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") } }