752 lines
26 KiB
Rust
752 lines
26 KiB
Rust
use axum::{
|
||
Json,
|
||
extract::{Extension, Path, State},
|
||
http::StatusCode,
|
||
response::Response,
|
||
};
|
||
use module_runtime::format_utc_micros;
|
||
use serde::Deserialize;
|
||
use serde_json::{Value, json};
|
||
use shared_contracts::runtime::{
|
||
BasicOkResponse, ProfileSaveArchiveListResponse, ProfileSaveArchiveResumeResponse,
|
||
ProfileSaveArchiveSummaryResponse, PutRuntimeSaveCheckpointRequest, 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<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
|
||
.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 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,
|
||
bottom_tab,
|
||
game_state,
|
||
existing.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 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 {
|
||
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, json};
|
||
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 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"));
|
||
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
|
||
}
|
||
|
||
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 {
|
||
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")
|
||
}
|
||
}
|