Files
Genarrative/server-rs/crates/api-server/src/runtime_save.rs
2026-04-28 19:36:39 +08:00

752 lines
26 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 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<PutRuntimeSaveCheckpointRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(path): Path<WorldKeyPath>,
) -> Result<Json<Value>, 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<String> {
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<String> {
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")
}
}