This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -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 {