This commit is contained in:
2026-05-01 16:08:19 +08:00
parent dce84f677d
commit 14208ccb64
15 changed files with 752 additions and 175 deletions

View File

@@ -1396,9 +1396,13 @@ pub async fn use_puzzle_runtime_prop(
));
}
};
let should_sync_freeze_boundary = matches!(prop_kind.as_str(), "freezeTime" | "freeze_time");
let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros());
let reducer_owner_user_id = owner_user_id.clone();
let run = execute_billable_asset_operation(
let reducer_run_id = run_id.clone();
let fallback_run_id = run_id.clone();
let fallback_owner_user_id = owner_user_id.clone();
let run_result = execute_billable_asset_operation(
&state,
&owner_user_id,
billing_asset_kind,
@@ -1407,7 +1411,7 @@ pub async fn use_puzzle_runtime_prop(
state
.spacetime_client()
.use_puzzle_runtime_prop(PuzzleRunPropRecordInput {
run_id,
run_id: reducer_run_id,
owner_user_id: reducer_owner_user_id,
prop_kind,
used_at_micros: current_utc_micros(),
@@ -1417,8 +1421,30 @@ pub async fn use_puzzle_runtime_prop(
.map_err(map_puzzle_client_error)
},
)
.await
.map_err(|error| puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error))?;
.await;
let run = match run_result {
Ok(run) => run,
Err(error) if should_sync_puzzle_freeze_boundary(&error, should_sync_freeze_boundary) => {
// 中文注释:冻结确认窗打开时前端会暂停视觉计时,但正式 run 仍可能在服务端边界帧先结算失败。
// 这类情况已由扣费包装器退款,此处只同步失败态快照,避免玩家看到“操作不合法”。
state
.spacetime_client()
.get_puzzle_run(fallback_run_id, fallback_owner_user_id)
.await
.map_err(map_puzzle_client_error)
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error)
})?
}
Err(error) => {
return Err(puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
error,
));
}
};
Ok(json_success_body(
Some(&request_context),
@@ -2503,6 +2529,10 @@ fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError {
}))
}
fn should_sync_puzzle_freeze_boundary(error: &AppError, is_freeze_time: bool) -> bool {
is_freeze_time && error.body_text().contains("操作不合法")
}
fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> bool {
matches!(error, SpacetimeClientError::Procedure(message) if
message.contains("save_puzzle_form_draft")
@@ -3580,6 +3610,26 @@ mod tests {
let response = error.into_response();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}
#[test]
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
let invalid_operation =
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "操作不合法",
}));
let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "陶泥币余额不足",
}));
assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true));
assert!(!should_sync_puzzle_freeze_boundary(
&invalid_operation,
false
));
assert!(!should_sync_puzzle_freeze_boundary(&other_error, true));
}
}
struct PuzzleDashScopeSettings {

View File

@@ -29,7 +29,8 @@ use shared_contracts::runtime::{
ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse,
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest,
RedeemProfileRewardCodeResponse,
};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
@@ -216,14 +217,27 @@ pub async fn get_profile_referral_invite_center(
}
pub async fn redeem_profile_referral_invite_code(
State(_state): State<AppState>,
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(_payload): Json<RedeemProfileReferralInviteCodeRequest>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RedeemProfileReferralInviteCodeRequest>,
) -> Result<Json<Value>, Response> {
Err(runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message("邀请码仅注册时填写"),
let user_id = authenticated.claims().user_id().to_string();
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.redeem_profile_referral_invite_code(user_id, payload.invite_code, updated_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),
build_redeem_profile_referral_invite_code_response(record),
))
}
@@ -507,6 +521,18 @@ fn build_profile_referral_invite_center_response(
}
}
fn build_redeem_profile_referral_invite_code_response(
record: module_runtime::RuntimeReferralRedeemRecord,
) -> RedeemProfileReferralInviteCodeResponse {
RedeemProfileReferralInviteCodeResponse {
center: build_profile_referral_invite_center_response(record.center),
invitee_reward_granted: record.invitee_reward_granted,
inviter_reward_granted: record.inviter_reward_granted,
invitee_balance_after: record.invitee_balance_after,
inviter_balance_after: record.inviter_balance_after,
}
}
fn build_redeem_profile_reward_code_response(
record: RuntimeProfileRewardCodeRedeemRecord,
) -> RedeemProfileRewardCodeResponse {
@@ -603,6 +629,7 @@ mod tests {
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::Value;
use std::time::Duration;
use time::OffsetDateTime;
use tower::ServiceExt;
@@ -759,7 +786,7 @@ mod tests {
}
#[tokio::test]
async fn profile_referral_redeem_code_rejects_authenticated_manual_fill() {
async fn profile_referral_redeem_code_calls_spacetime_for_authenticated_user() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
@@ -777,7 +804,7 @@ mod tests {
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
@@ -787,8 +814,8 @@ mod tests {
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
Value::String("邀请码仅注册时填写".to_string())
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
@@ -900,7 +927,7 @@ mod tests {
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
let state = AppState::new(fast_spacetime_timeout_config()).expect("state should build");
state
.seed_test_phone_user_with_password("13800138104", "secret123")
.await
@@ -908,6 +935,13 @@ mod tests {
state
}
fn fast_spacetime_timeout_config() -> AppConfig {
AppConfig {
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
}
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {