514 lines
17 KiB
Rust
514 lines
17 KiB
Rust
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<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: entry.source_type.as_str().to_string(),
|
|
created_at: entry.created_at,
|
|
})
|
|
.collect(),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn get_profile_recharge_center(
|
|
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_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<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Json(payload): Json<CreateProfileRechargeOrderRequest>,
|
|
) -> Result<Json<Value>, 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<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))
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|