Files
Genarrative/server-rs/crates/api-server/src/runtime_save.rs

409 lines
14 KiB
Rust

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
.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<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
.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))
})?;
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 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
.seed_test_phone_user_with_password("13800138105", "secret123")
.await
.id;
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: 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")
}
}