use axum::{ Json, extract::{Extension, State, rejection::JsonRejection}, http::StatusCode, response::Response, }; use module_runtime::{ RuntimePlatformTheme, RuntimeSettingsFieldError, build_runtime_setting_upsert_input, }; use serde_json::{Value, json}; use shared_contracts::runtime::{ PutRuntimeSettingsRequest, RUNTIME_PLATFORM_THEME_DARK, RUNTIME_PLATFORM_THEME_LIGHT, RuntimeSettingsResponse, }; use spacetime_client::SpacetimeClientError; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, state::AppState, }; pub async fn get_runtime_settings( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let settings = state .spacetime_client() .get_runtime_settings(user_id) .await .map_err(|error| { runtime_settings_error_response( &request_context, map_runtime_settings_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), RuntimeSettingsResponse { music_volume: settings.music_volume, platform_theme: settings.platform_theme.as_str().to_string(), }, )) } pub async fn put_runtime_settings( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { runtime_settings_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-settings", "message": error.body_text(), })), ) })?; let user_id = authenticated.claims().user_id().to_string(); let theme = parse_platform_theme_strict(&payload.platform_theme).ok_or_else(|| { runtime_settings_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-settings", "message": "platformTheme 仅支持 light 或 dark", })), ) })?; if !(0.0..=1.0).contains(&payload.music_volume) { return Err(runtime_settings_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-settings", "message": "musicVolume 必须在 0 到 1 之间", })), )); } let now_micros = current_utc_micros(); let prepared = build_runtime_setting_upsert_input(user_id, payload.music_volume, theme, now_micros) .map_err(|error| { runtime_settings_error_response( &request_context, map_runtime_settings_prepare_error(error), ) })?; let settings = state .spacetime_client() .put_runtime_settings( prepared.user_id, prepared.music_volume, prepared.platform_theme, prepared.updated_at_micros, ) .await .map_err(|error| { runtime_settings_error_response( &request_context, map_runtime_settings_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), RuntimeSettingsResponse { music_volume: settings.music_volume, platform_theme: settings.platform_theme.as_str().to_string(), }, )) } fn map_runtime_settings_prepare_error(error: RuntimeSettingsFieldError) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-settings", "message": error.to_string(), })) } fn map_runtime_settings_client_error(error: SpacetimeClientError) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn runtime_settings_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } fn parse_platform_theme_strict(raw: &str) -> Option { match raw.trim() { RUNTIME_PLATFORM_THEME_LIGHT => Some(RuntimePlatformTheme::Light), RUNTIME_PLATFORM_THEME_DARK => Some(RuntimePlatformTheme::Dark), _ => None, } } fn current_utc_micros() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock should be after unix epoch"); i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") } #[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_settings_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/settings") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn runtime_settings_returns_bad_gateway_when_spacetime_not_published() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); let app = build_router(state); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/runtime/settings") .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_GATEWAY); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["ok"], Value::Bool(false)); assert_eq!( payload["error"]["details"]["provider"], Value::String("spacetimedb".to_string()) ); } #[tokio::test] async fn runtime_settings_rejects_invalid_theme_with_envelope() { 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/settings") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "musicVolume": 0.42, "platformTheme": "mythic" }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["ok"], Value::Bool(false)); assert_eq!( payload["error"]["details"]["provider"], Value::String("runtime-settings".to_string()) ); } #[tokio::test] #[ignore = "需要本地 SpacetimeDB xushi-p4wfr 已启动并发布当前 module;验证 PUT/GET settings 主链"] async fn runtime_settings_round_trip_against_local_spacetimedb() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); let app = build_router(state); let put_response = app .clone() .oneshot( Request::builder() .method("PUT") .uri("/api/runtime/settings") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "musicVolume": 1.4, "platformTheme": "dark" }) .to_string(), )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(put_response.status(), StatusCode::OK); let put_body = put_response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let put_payload: Value = serde_json::from_slice(&put_body).expect("response body should be valid json"); assert_eq!( put_payload["data"]["platformTheme"], Value::String("dark".to_string()) ); assert_eq!(put_payload["data"]["musicVolume"], json!(1.0)); let get_response = app .oneshot( Request::builder() .method("GET") .uri("/api/runtime/settings") .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!(get_response.status(), StatusCode::OK); let get_body = get_response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let get_payload: Value = serde_json::from_slice(&get_body).expect("response body should be valid json"); assert_eq!( get_payload["data"]["platformTheme"], Value::String("dark".to_string()) ); assert_eq!(get_payload["data"]["musicVolume"], json!(1.0)); } async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state .seed_test_phone_user_with_password("13800138106", "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: state.seed_test_refresh_session_for_user_id( "user_00000001", "sess_runtime_settings", ), 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") } }