修复退款 outbox 幂等恢复
允许同一退款 ledger 在不同恢复路径使用不同诊断 reason 兼容旧本机 spool 文件推断缺失的外部生成 attempt 补充跨路径幂等与历史文件恢复测试
This commit is contained in:
@@ -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<u32> {
|
||||
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::<u32>().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");
|
||||
|
||||
@@ -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<u32>,
|
||||
) -> 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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user