feat: complete M3 runtime snapshot and profile save archive
This commit is contained in:
@@ -49,6 +49,10 @@ use crate::{
|
||||
},
|
||||
runtime_inventory::get_runtime_inventory_state,
|
||||
runtime_profile::{get_profile_dashboard, get_profile_play_stats, get_profile_wallet_ledger},
|
||||
runtime_save::{
|
||||
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
|
||||
put_runtime_snapshot, resume_profile_save_archive,
|
||||
},
|
||||
runtime_settings::{get_runtime_settings, put_runtime_settings},
|
||||
runtime_story::resolve_runtime_story_state,
|
||||
state::AppState,
|
||||
@@ -227,6 +231,16 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/runtime/settings",
|
||||
get(get_runtime_settings)
|
||||
.put(put_runtime_settings)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/save/snapshot",
|
||||
get(get_runtime_snapshot)
|
||||
.put(put_runtime_snapshot)
|
||||
.delete(delete_runtime_snapshot)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
@@ -366,6 +380,34 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/profile/save-archives",
|
||||
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/save-archives",
|
||||
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/profile/save-archives/{world_key}",
|
||||
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/save-archives/{world_key}",
|
||||
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/sessions/{runtime_session_id}/inventory",
|
||||
get(get_runtime_inventory_state).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -23,6 +23,7 @@ mod response_headers;
|
||||
mod runtime_browse_history;
|
||||
mod runtime_inventory;
|
||||
mod runtime_profile;
|
||||
mod runtime_save;
|
||||
mod runtime_settings;
|
||||
mod runtime_story;
|
||||
mod session_client;
|
||||
|
||||
401
server-rs/crates/api-server/src/runtime_save.rs
Normal file
401
server-rs/crates/api-server/src/runtime_save.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
BasicOkResponse, ProfileSaveArchiveListResponse, ProfileSaveArchiveResumeResponse,
|
||||
ProfileSaveArchiveSummaryResponse, PutSavedGameSnapshotRequest, 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
|
||||
.spacetime_client()
|
||||
.get_runtime_snapshot(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<PutSavedGameSnapshotRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
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 record = state
|
||||
.spacetime_client()
|
||||
.put_runtime_snapshot(
|
||||
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)))?;
|
||||
|
||||
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
|
||||
.spacetime_client()
|
||||
.delete_runtime_snapshot(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 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;
|
||||
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 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
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "runtime_save_user".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
state
|
||||
}
|
||||
|
||||
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: 1,
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user