use axum::{ Json, extract::{Extension, State}, http::StatusCode, response::Response, }; use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord, }; use serde_json::{Value, json}; use shared_contracts::runtime::{ CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, ProfileDashboardSummaryResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, }; use spacetime_client::SpacetimeClientError; use time::OffsetDateTime; 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: entry.source_type.as_str().to_string(), created_at: entry.created_at, }) .collect(), }, )) } pub async fn get_profile_recharge_center( 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_recharge_center(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), build_profile_recharge_center_response(record), )) } pub async fn create_profile_recharge_order( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let payment_channel = payload .payment_channel .unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string()); let created_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let (center, order) = state .spacetime_client() .create_profile_recharge_order( user_id, payload.product_id, payment_channel, created_at_micros as i64, ) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), CreateProfileRechargeOrderResponse { order: build_profile_recharge_order_response(order), center: build_profile_recharge_center_response(center), }, )) } 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)) } fn build_profile_recharge_center_response( record: RuntimeProfileRechargeCenterRecord, ) -> ProfileRechargeCenterResponse { ProfileRechargeCenterResponse { wallet_balance: record.wallet_balance, membership: ProfileMembershipResponse { status: record.membership.status.as_str().to_string(), tier: record.membership.tier.as_str().to_string(), started_at: record.membership.started_at, expires_at: record.membership.expires_at, updated_at: record.membership.updated_at, }, point_products: record .point_products .into_iter() .map(build_profile_recharge_product_response) .collect(), membership_products: record .membership_products .into_iter() .map(build_profile_recharge_product_response) .collect(), benefits: record .benefits .into_iter() .map(build_profile_membership_benefit_response) .collect(), latest_order: record .latest_order .map(build_profile_recharge_order_response), has_points_recharged: record.has_points_recharged, } } fn build_profile_recharge_product_response( record: RuntimeProfileRechargeProductRecord, ) -> ProfileRechargeProductResponse { ProfileRechargeProductResponse { product_id: record.product_id, title: record.title, price_cents: record.price_cents, kind: record.kind.as_str().to_string(), points_amount: record.points_amount, bonus_points: record.bonus_points, duration_days: record.duration_days, badge_label: record.badge_label, description: record.description, tier: record.tier.as_str().to_string(), } } fn build_profile_membership_benefit_response( record: RuntimeProfileMembershipBenefitRecord, ) -> ProfileMembershipBenefitResponse { ProfileMembershipBenefitResponse { benefit_name: record.benefit_name, normal_value: record.normal_value, month_value: record.month_value, season_value: record.season_value, year_value: record.year_value, } } fn build_profile_recharge_order_response( record: RuntimeProfileRechargeOrderRecord, ) -> ProfileRechargeOrderResponse { ProfileRechargeOrderResponse { order_id: record.order_id, product_id: record.product_id, product_title: record.product_title, kind: record.kind.as_str().to_string(), amount_cents: record.amount_cents, status: record.status.as_str().to_string(), payment_channel: record.payment_channel, paid_at: record.paid_at, created_at: record.created_at, points_delta: record.points_delta, membership_expires_at: record.membership_expires_at, } } #[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_recharge_center_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/profile/recharge-center") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_recharge_order_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/recharge/orders") .header("content-type", "application/json") .body(Body::from(r#"{"productId":"points_10"}"#)) .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") } }