修复退款 outbox 幂等恢复
Project CI / Native shell tests (pull_request) Successful in 14m19s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 3m34s

允许同一退款 ledger 在不同恢复路径使用不同诊断 reason

兼容旧本机 spool 文件推断缺失的外部生成 attempt

补充跨路径幂等与历史文件恢复测试
This commit is contained in:
2026-08-27 16:58:21 +08:00
parent 426c20b476
commit a0bf2ba374
2 changed files with 63 additions and 7 deletions
@@ -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
{