use axum::{ Json, extract::{Extension, State, rejection::JsonRejection}, http::StatusCode, response::Response, }; use module_runtime::{MAX_BROWSE_HISTORY_BATCH_SIZE, RuntimeBrowseHistoryWriteInput}; use serde_json::{Value, json}; use shared_contracts::runtime::{ BROWSE_HISTORY_THEME_MODE_ARCANE, BROWSE_HISTORY_THEME_MODE_MACHINA, BROWSE_HISTORY_THEME_MODE_MARTIAL, BROWSE_HISTORY_THEME_MODE_MYTHIC, BROWSE_HISTORY_THEME_MODE_RIFT, BROWSE_HISTORY_THEME_MODE_TIDE, PlatformBrowseHistoryEntryResponse, PlatformBrowseHistoryResponse, PlatformBrowseHistoryUpsertRequest, PlatformBrowseHistoryWriteEntryRequest, }; 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_browse_history( 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_platform_browse_history(user_id) .await .map_err(|error| { runtime_browse_history_error_response( &request_context, map_runtime_browse_history_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PlatformBrowseHistoryResponse { entries: entries .into_iter() .map(map_browse_history_entry_response) .collect(), }, )) } pub async fn post_runtime_browse_history( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { runtime_browse_history_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "browse-history", "message": error.body_text(), })), ) })?; let now_micros = current_utc_micros(); let user_id = authenticated.claims().user_id().to_string(); let request_entries = payload.into_entries(); validate_browse_history_request_entries(&request_context, &request_entries)?; let entries = request_entries .into_iter() .map(|entry| RuntimeBrowseHistoryWriteInput { owner_user_id: entry.owner_user_id, profile_id: entry.profile_id, world_name: entry.world_name, subtitle: entry.subtitle, summary_text: entry.summary_text, cover_image_src: entry.cover_image_src, theme_mode: entry.theme_mode, author_display_name: entry.author_display_name, visited_at: entry.visited_at, }) .collect::>(); let entries = state .spacetime_client() .upsert_platform_browse_history_entries(user_id, entries, now_micros) .await .map_err(|error| { runtime_browse_history_error_response( &request_context, map_runtime_browse_history_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PlatformBrowseHistoryResponse { entries: entries .into_iter() .map(map_browse_history_entry_response) .collect(), }, )) } pub async fn delete_runtime_browse_history( 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() .clear_platform_browse_history(user_id) .await .map_err(|error| { runtime_browse_history_error_response( &request_context, map_runtime_browse_history_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PlatformBrowseHistoryResponse { entries: entries .into_iter() .map(map_browse_history_entry_response) .collect(), }, )) } fn map_browse_history_entry_response( entry: module_runtime::RuntimeBrowseHistoryRecord, ) -> PlatformBrowseHistoryEntryResponse { PlatformBrowseHistoryEntryResponse { owner_user_id: entry.owner_user_id, profile_id: entry.profile_id, world_name: entry.world_name, subtitle: entry.subtitle, summary_text: entry.summary_text, cover_image_src: entry.cover_image_src, theme_mode: map_browse_history_theme_mode(entry.theme_mode).to_string(), author_display_name: entry.author_display_name, visited_at: entry.visited_at, } } fn map_browse_history_theme_mode( value: module_runtime::RuntimeBrowseHistoryThemeMode, ) -> &'static str { match value { module_runtime::RuntimeBrowseHistoryThemeMode::Martial => BROWSE_HISTORY_THEME_MODE_MARTIAL, module_runtime::RuntimeBrowseHistoryThemeMode::Arcane => BROWSE_HISTORY_THEME_MODE_ARCANE, module_runtime::RuntimeBrowseHistoryThemeMode::Machina => BROWSE_HISTORY_THEME_MODE_MACHINA, module_runtime::RuntimeBrowseHistoryThemeMode::Tide => BROWSE_HISTORY_THEME_MODE_TIDE, module_runtime::RuntimeBrowseHistoryThemeMode::Rift => BROWSE_HISTORY_THEME_MODE_RIFT, module_runtime::RuntimeBrowseHistoryThemeMode::Mythic => BROWSE_HISTORY_THEME_MODE_MYTHIC, } } fn map_runtime_browse_history_client_error(error: SpacetimeClientError) -> AppError { let (status, provider) = match error { // 这类错误发生在 Rust 本地 DTO 构建阶段,语义上属于请求不合法,而不是下游不可用。 SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "browse-history"), _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), }; AppError::from_status(status).with_details(json!({ "provider": provider, "message": error.to_string(), })) } fn runtime_browse_history_error_response( request_context: &RequestContext, error: AppError, ) -> Response { error.into_response_with_context(Some(request_context)) } fn validate_browse_history_request_entries( request_context: &RequestContext, entries: &[PlatformBrowseHistoryWriteEntryRequest], ) -> Result<(), Response> { if entries.len() > MAX_BROWSE_HISTORY_BATCH_SIZE { return Err(runtime_browse_history_error_response( request_context, browse_history_bad_request(format!( "entries 单次最多只允许 {} 条", MAX_BROWSE_HISTORY_BATCH_SIZE )), )); } for entry in entries { if entry.owner_user_id.trim().is_empty() { return Err(runtime_browse_history_error_response( request_context, browse_history_bad_request("ownerUserId 不能为空"), )); } if entry.profile_id.trim().is_empty() { return Err(runtime_browse_history_error_response( request_context, browse_history_bad_request("profileId 不能为空"), )); } if entry.world_name.trim().is_empty() { return Err(runtime_browse_history_error_response( request_context, browse_history_bad_request("worldName 不能为空"), )); } } Ok(()) } fn browse_history_bad_request(message: impl Into) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "browse-history", "message": message.into(), })) } 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_browse_history_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/browse-history") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn runtime_browse_history_rejects_blank_required_fields() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/browse-history") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "ownerUserId": " ", "profileId": "profile-1", "worldName": "世界A" }) .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("browse-history".to_string()) ); } #[tokio::test] async fn runtime_browse_history_accepts_batch_shape_and_surfaces_backend_failure_as_bad_gateway() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/browse-history") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-genarrative-response-envelope", "v1") .body(Body::from( json!({ "entries": [{ "ownerUserId": "owner-1", "profileId": "profile-1", "worldName": "世界A" }] }) .to_string(), )) .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()) ); } async fn seed_authenticated_state() -> AppState { let state = AppState::new(AppConfig::default()).expect("state should build"); state .seed_test_phone_user_with_password("13800138102", "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_browse_history".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") } }