369 lines
13 KiB
Rust
369 lines
13 KiB
Rust
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<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, 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<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<PutRuntimeSettingsRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, 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<RuntimePlatformTheme> {
|
||
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: "sess_runtime_settings".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")
|
||
}
|
||
}
|