fix: harden queue retries and quota reservation
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user