1
This commit is contained in:
@@ -4,12 +4,12 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
|
||||
use module_runtime::format_utc_micros;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
BasicOkResponse, ProfileSaveArchiveListResponse, ProfileSaveArchiveResumeResponse,
|
||||
ProfileSaveArchiveSummaryResponse, PutSavedGameSnapshotRequest, SavedGameSnapshotResponse,
|
||||
ProfileSaveArchiveSummaryResponse, PutRuntimeSaveCheckpointRequest, SavedGameSnapshotResponse,
|
||||
};
|
||||
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
@@ -49,9 +49,29 @@ pub async fn put_runtime_snapshot(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<PutSavedGameSnapshotRequest>,
|
||||
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
|
||||
@@ -71,30 +91,37 @@ pub async fn put_runtime_snapshot(
|
||||
let updated_at_micros = offset_datetime_to_unix_micros(now);
|
||||
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
|
||||
|
||||
let record = if is_non_persistent_runtime_snapshot(&payload.game_state) {
|
||||
build_transient_runtime_snapshot_record(
|
||||
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,
|
||||
payload.bottom_tab,
|
||||
payload.game_state,
|
||||
payload.current_story,
|
||||
bottom_tab,
|
||||
game_state,
|
||||
existing.current_story,
|
||||
updated_at_micros,
|
||||
)
|
||||
} else {
|
||||
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))
|
||||
})?
|
||||
};
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -196,30 +223,6 @@ fn build_saved_game_snapshot_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_transient_runtime_snapshot_record(
|
||||
user_id: String,
|
||||
saved_at_micros: i64,
|
||||
bottom_tab: String,
|
||||
game_state: Value,
|
||||
current_story: Option<Value>,
|
||||
updated_at_micros: i64,
|
||||
) -> module_runtime::RuntimeSnapshotRecord {
|
||||
// 中文注释:预览/测试入口可得到本次响应,但不能覆盖用户正式当前快照。
|
||||
module_runtime::RuntimeSnapshotRecord {
|
||||
user_id,
|
||||
version: SAVE_SNAPSHOT_VERSION,
|
||||
saved_at: format_utc_micros(saved_at_micros),
|
||||
saved_at_micros,
|
||||
bottom_tab,
|
||||
game_state_json: game_state.to_string(),
|
||||
current_story_json: current_story.as_ref().map(Value::to_string),
|
||||
game_state,
|
||||
current_story,
|
||||
created_at_micros: updated_at_micros,
|
||||
updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
|
||||
let Some(game_state) = game_state.as_object() else {
|
||||
return false;
|
||||
@@ -242,6 +245,110 @@ fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -302,7 +409,7 @@ mod tests {
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -325,6 +432,151 @@ mod tests {
|
||||
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"));
|
||||
@@ -444,6 +696,39 @@ mod tests {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user