Files
Genarrative/server-rs/crates/api-server/src/runtime_profile.rs
2026-04-22 12:34:49 +08:00

333 lines
11 KiB
Rust

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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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")
}
}