Integrate unfinished server-rs refactor worklists

This commit is contained in:
2026-04-30 13:39:06 +08:00
parent 62934b0809
commit 7ab0933f6d
676 changed files with 24487 additions and 21531 deletions

View File

@@ -4,7 +4,10 @@ use axum::{
http::StatusCode,
response::Response,
};
use module_runtime::format_utc_micros;
use module_runtime::{
RuntimeProfileFieldError, build_runtime_save_checkpoint_input,
build_runtime_save_checkpoint_update,
};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::runtime::{
@@ -52,26 +55,6 @@ pub async fn put_runtime_snapshot(
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
@@ -90,6 +73,15 @@ pub async fn put_runtime_snapshot(
.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 checkpoint_input = build_runtime_save_checkpoint_input(
payload.session_id,
payload.bottom_tab,
saved_at_micros,
updated_at_micros,
)
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_domain_error(error))
})?;
let existing = state
.get_runtime_snapshot_record(user_id.clone())
@@ -107,16 +99,18 @@ pub async fn put_runtime_snapshot(
)
})?;
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 update =
build_runtime_save_checkpoint_update(checkpoint_input, existing).map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_domain_error(error))
})?;
let record = state
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
bottom_tab,
game_state,
existing.current_story,
updated_at_micros,
update.saved_at_micros,
update.bottom_tab,
update.game_state,
update.current_story,
update.updated_at_micros,
)
.await
.map_err(|error| {
@@ -223,132 +217,6 @@ fn build_saved_game_snapshot_response(
}
}
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 {
@@ -395,6 +263,51 @@ fn map_runtime_save_resume_client_error(error: SpacetimeClientError) -> AppError
}))
}
fn map_runtime_save_domain_error(error: RuntimeProfileFieldError) -> AppError {
let message = error.to_string();
match error {
RuntimeProfileFieldError::MissingCheckpointSessionId => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "sessionId",
"message": "sessionId 不能为空",
}))
}
RuntimeProfileFieldError::MissingRuntimeSessionId => {
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
}))
}
RuntimeProfileFieldError::MissingBottomTab => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"field": "bottomTab",
"message": "bottomTab 不能为空",
}))
}
RuntimeProfileFieldError::NonPersistentRuntimeSnapshot => {
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
}))
}
RuntimeProfileFieldError::RuntimeSessionMismatch {
expected_session_id,
actual_session_id,
} => AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-save",
"message": message,
"expectedSessionId": expected_session_id,
"actualSessionId": actual_session_id,
})),
_ => AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-save",
"message": message,
})),
}
}
fn runtime_save_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -584,7 +497,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/profile/save-archives")
.uri("/api/profile/save-archives")
.body(Body::empty())
.expect("request should build"),
)
@@ -594,15 +507,6 @@ mod tests {
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;
@@ -613,7 +517,7 @@ mod tests {
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/profile/save-archives/%20%20")
.uri("/api/profile/save-archives/%20%20")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
@@ -625,68 +529,6 @@ mod tests {
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