From a0bf2ba3746b13a2a0add6c666eaf03512cdb608 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 27 Aug 2026 16:58:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=80=E6=AC=BE=20outbox?= =?UTF-8?q?=20=E5=B9=82=E7=AD=89=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 允许同一退款 ledger 在不同恢复路径使用不同诊断 reason 兼容旧本机 spool 文件推断缺失的外部生成 attempt 补充跨路径幂等与历史文件恢复测试 --- .../api-server/src/wallet_refund_outbox.rs | 22 ++++++++- .../src/runtime/active/profile.rs | 48 ++++++++++++++++--- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/server-rs/crates/api-server/src/wallet_refund_outbox.rs b/server-rs/crates/api-server/src/wallet_refund_outbox.rs index b260ad1c3..bb2232a52 100644 --- a/server-rs/crates/api-server/src/wallet_refund_outbox.rs +++ b/server-rs/crates/api-server/src/wallet_refund_outbox.rs @@ -329,7 +329,9 @@ impl WalletRefundOutbox { record.asset_id.clone(), record.settlement_reason.clone(), record.external_generation_job_id.clone(), - record.external_generation_claim_attempt, + record + .external_generation_claim_attempt + .or_else(|| infer_external_generation_claim_attempt(&record)), ) .map_err(|error| { WalletRefundOutboxError::Spacetime(SpacetimeClientError::Runtime( @@ -575,6 +577,15 @@ fn default_settlement_reason() -> String { "emergency_spool_replay".to_string() } +fn infer_external_generation_claim_attempt(record: &WalletRefundOutboxRecord) -> Option { + let job_id = record.external_generation_job_id.as_deref()?.trim(); + let prefix = format!("asset_operation_refund:external_generation_job:{job_id}:attempt:"); + record + .ledger_id + .strip_prefix(&prefix) + .and_then(|value| value.parse::().ok()) +} + impl fmt::Debug for WalletRefundOutbox { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("WalletRefundOutbox") @@ -765,6 +776,15 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn legacy_external_refund_record_infers_missing_claim_attempt() { + let mut record = + sample_record("asset_operation_refund:external_generation_job:extgen-test:attempt:7"); + record.external_generation_claim_attempt = None; + + assert_eq!(infer_external_generation_claim_attempt(&record), Some(7)); + } + #[tokio::test] async fn enqueue_drops_when_outbox_exceeds_max_bytes() { let dir = test_dir("max-bytes"); diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index e3610969e..e553a58e4 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -2933,6 +2933,45 @@ mod tests { ); } + #[test] + fn refund_outbox_idempotency_allows_recovery_reason_to_change() { + let row = ProfileWalletRefundOutbox { + refund_ledger_id: "asset_operation_refund:external_generation_job:job-1:attempt:1" + .to_string(), + consume_ledger_id: "asset_operation_consume:external_generation_job:job-1:attempt:1" + .to_string(), + owner_user_id: "user-1".to_string(), + amount: 37, + created_at: Timestamp::from_micros_since_unix_epoch(1), + asset_kind: "editor-image".to_string(), + asset_id: "asset-1".to_string(), + settlement_reason: "worker_attempt_failed".to_string(), + external_generation_job_id: Some("job-1".to_string()), + external_generation_claim_attempt: Some(1), + status: PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING.to_string(), + available_at: Timestamp::from_micros_since_unix_epoch(1), + attempts: 0, + last_error: None, + last_attempted_at: None, + last_worker_id: None, + }; + + assert!( + validate_profile_wallet_refund_outbox_fact( + &row, + &row.refund_ledger_id, + &row.consume_ledger_id, + &row.owner_user_id, + row.amount, + &row.asset_kind, + &row.asset_id, + Some("job-1"), + Some(1), + ) + .is_ok() + ); + } + #[test] fn wallet_idempotent_replay_requires_matching_user_amount_and_source() { let existing = asset_operation_wallet_ledger( @@ -9291,7 +9330,6 @@ pub(crate) fn enqueue_profile_wallet_refund_outbox_tx( amount, &asset_kind, &asset_id, - &settlement_reason, external_generation_job_id.as_deref(), external_generation_claim_attempt, )?; @@ -9329,7 +9367,6 @@ pub(crate) fn enqueue_profile_wallet_refund_outbox_tx( amount, &asset_kind, &asset_id, - &settlement_reason, external_generation_job_id.as_deref(), external_generation_claim_attempt, )?; @@ -9375,7 +9412,6 @@ fn validate_profile_wallet_refund_outbox_fact( expected_amount: u64, expected_asset_kind: &str, expected_asset_id: &str, - expected_settlement_reason: &str, expected_external_generation_job_id: Option<&str>, expected_external_generation_claim_attempt: Option, ) -> Result<(), String> { @@ -9389,9 +9425,9 @@ fn validate_profile_wallet_refund_outbox_fact( if row.asset_kind != expected_asset_kind || row.asset_id != expected_asset_id { return Err("资产操作退款 outbox 资源事实不匹配".to_string()); } - if row.settlement_reason != expected_settlement_reason { - return Err("资产操作退款 outbox settlement_reason 不匹配".to_string()); - } + // settlement_reason is diagnostic context, not an idempotency fact. A failed + // attempt can be observed first by its failure transaction and later by stale + // attempt recovery, which legitimately use different reason labels. if row.external_generation_job_id.as_deref() != expected_external_generation_job_id || row.external_generation_claim_attempt != expected_external_generation_claim_attempt {