init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,368 @@
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")
}
}