fix: harden queue retries and quota reservation

This commit is contained in:
237899745
2026-07-25 18:15:39 +08:00
parent 091a0cee75
commit f389dfb567
9 changed files with 358 additions and 101 deletions

View File

@@ -52,11 +52,23 @@ async fn stripe_webhook(
AppError::new(ErrorCode::InvalidRequest, "Webhook JSON 解析失败").with_source(err)
})?;
let inserted: Option<String> = sqlx::query_scalar(
let claimed: Option<String> = sqlx::query_scalar(
r#"
INSERT INTO webhook_events (provider, provider_event_id, event_type, payload)
VALUES ('stripe', $1, $2, $3)
ON CONFLICT (provider, provider_event_id) DO NOTHING
INSERT INTO webhook_events (
provider, provider_event_id, event_type, payload, status
) VALUES ('stripe', $1, $2, $3, 'processing')
ON CONFLICT (provider, provider_event_id) DO UPDATE
SET event_type = EXCLUDED.event_type,
payload = EXCLUDED.payload,
received_at = NOW(),
processed_at = NULL,
status = 'processing',
error_message = NULL
WHERE webhook_events.status IN ('received', 'failed')
OR (
webhook_events.status = 'processing'
AND webhook_events.received_at < NOW() - INTERVAL '5 minutes'
)
RETURNING provider_event_id
"#,
)
@@ -67,16 +79,29 @@ async fn stripe_webhook(
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "Webhook 入库失败").with_source(err))?;
if inserted.is_none() {
return Ok(Json(Envelope {
success: true,
data: serde_json::json!({ "status": "duplicate" }),
}));
if claimed.is_none() {
let status: Option<String> = sqlx::query_scalar(
"SELECT status FROM webhook_events WHERE provider = 'stripe' AND provider_event_id = $1",
)
.bind(&event.id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "Webhook 状态查询失败").with_source(err))?;
if status.as_deref() == Some("processed") {
return Ok(Json(Envelope {
success: true,
data: serde_json::json!({ "status": "duplicate" }),
}));
}
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"Webhook 事件正在处理,请稍后重试",
));
}
if let Err(err) = process_stripe_event(&state, &event).await {
let _ = sqlx::query(
"UPDATE webhook_events SET status = 'failed', error_message = $2, processed_at = NOW() WHERE provider = 'stripe' AND provider_event_id = $1",
"UPDATE webhook_events SET status = 'failed', error_message = $2, processed_at = NULL WHERE provider = 'stripe' AND provider_event_id = $1 AND status = 'processing'",
)
.bind(&event.id)
.bind(err.to_string())
@@ -528,7 +553,11 @@ async fn upsert_invoice(state: &AppState, object: &serde_json::Value) -> Result<
fn truncate(mut s: String, max: usize) -> String {
if s.len() > max {
s.truncate(max);
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
s.truncate(end);
}
s
}
@@ -552,3 +581,15 @@ fn map_invoice_status(status: &str) -> &'static str {
_ => "open",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_preserves_utf8_boundaries() {
assert_eq!(truncate("中文测试".to_string(), 5), "");
assert_eq!(truncate("abc中文".to_string(), 5), "abc");
assert_eq!(truncate("short".to_string(), 20), "short");
}
}