393 lines
14 KiB
Rust
393 lines
14 KiB
Rust
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<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_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<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
payload: Result<Json<PlatformBrowseHistoryUpsertRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, 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::<Vec<_>>();
|
|
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<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()
|
|
.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<String>) -> 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")
|
|
}
|
|
}
|