use axum::{ Json, extract::{Extension, State}, http::StatusCode, response::Response, }; use serde_json::{Value, json}; use shared_contracts::runtime::{ PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, }; 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_profile_dashboard( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let record = state .spacetime_client() .get_profile_dashboard(user_id) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), ProfileDashboardSummaryResponse { wallet_balance: record.wallet_balance, total_play_time_ms: record.total_play_time_ms, played_world_count: record.played_world_count, updated_at: record.updated_at, }, )) } pub async fn get_profile_wallet_ledger( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let entries = state .spacetime_client() .list_profile_wallet_ledger(user_id) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), ProfileWalletLedgerResponse { entries: entries .into_iter() .map(|entry| ProfileWalletLedgerEntryResponse { id: entry.wallet_ledger_id, amount_delta: entry.amount_delta, balance_after: entry.balance_after, source_type: match entry.source_type { module_runtime::RuntimeProfileWalletLedgerSourceType::SnapshotSync => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string() } }, created_at: entry.created_at, }) .collect(), }, )) } pub async fn get_profile_play_stats( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let record = state .spacetime_client() .get_profile_play_stats(user_id) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), ProfilePlayStatsResponse { total_play_time_ms: record.total_play_time_ms, played_works: record .played_works .into_iter() .map(|entry| ProfilePlayedWorkSummaryResponse { world_key: entry.world_key, owner_user_id: entry.owner_user_id, profile_id: entry.profile_id, world_type: entry.world_type, world_title: entry.world_title, world_subtitle: entry.world_subtitle, first_played_at: entry.first_played_at, last_played_at: entry.last_played_at, last_observed_play_time_ms: entry.last_observed_play_time_ms, }) .collect(), updated_at: record.updated_at, }, )) } fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { let (status, provider) = match error { SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"), _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), }; AppError::from_status(status).with_details(json!({ "provider": provider, "message": error.to_string(), })) } fn runtime_profile_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 profile_dashboard_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/dashboard") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_wallet_ledger_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/wallet-ledger") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_play_stats_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/play-stats") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_dashboard_compat_route_matches_main_route_error_shape() { assert_compat_route_matches_main_route_error_shape( "/api/runtime/profile/dashboard", "/api/profile/dashboard", ) .await; } #[tokio::test] async fn profile_wallet_ledger_compat_route_matches_main_route_error_shape() { assert_compat_route_matches_main_route_error_shape( "/api/runtime/profile/wallet-ledger", "/api/profile/wallet-ledger", ) .await; } #[tokio::test] async fn profile_play_stats_compat_route_matches_main_route_error_shape() { assert_compat_route_matches_main_route_error_shape( "/api/runtime/profile/play-stats", "/api/profile/play-stats", ) .await; } 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_body = main_response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let compat_body = compat_response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let main_payload: Value = serde_json::from_slice(&main_body).expect("response body should be valid json"); let compat_payload: Value = serde_json::from_slice(&compat_body).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 .password_entry_service() .execute(module_auth::PasswordEntryInput { username: "runtime_profile_user".to_string(), password: "secret123".to_string(), }) .await .expect("seed login should succeed"); state } fn issue_access_token(state: &AppState) -> String { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), session_id: "sess_runtime_profile".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 1, 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") } }