feat(saas): add webhook event notification system (@unplugged)
Webhook infrastructure for external event notifications: - SQL migration: webhook_subscriptions + webhook_deliveries tables - Types: CreateWebhookRequest, UpdateWebhookRequest, WebhookDelivery - Service: CRUD operations + trigger_webhooks + HMAC-SHA256 signing - Handlers: REST API endpoints (CRUD + delivery logs) - Worker: WebhookDeliveryWorker with exponential retry (max 3) NOT YET INTEGRATED: needs mod registration in lib.rs + workers/mod.rs, hmac crate dependency, and route mounting. Code is ready for future integration after stabilization phase completes.
This commit is contained in:
175
crates/zclaw-saas/src/workers/webhook_delivery.rs
Normal file
175
crates/zclaw-saas/src/workers/webhook_delivery.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! Webhook 投递 Worker
|
||||
//!
|
||||
//! 从 webhook_deliveries 表取出待投递记录,向目标 URL 发送 HTTP POST。
|
||||
//! 使用 HMAC-SHA256 签名 payload,接收方可用 X-Webhook-Signature 头验证。
|
||||
//! 投递失败时指数退避重试(最多 3 次)。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use hmac::{Hmac, Mac};
|
||||
use crate::error::SaasResult;
|
||||
use super::Worker;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Worker 参数(当前未使用 — Worker 通过 poll DB 工作)
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WebhookDeliveryArgs {
|
||||
pub delivery_id: String,
|
||||
}
|
||||
|
||||
/// 最大投递尝试次数
|
||||
const MAX_ATTEMPTS: i32 = 3;
|
||||
|
||||
/// 每轮轮询获取的最大投递数
|
||||
const POLL_LIMIT: i32 = 50;
|
||||
|
||||
pub struct WebhookDeliveryWorker;
|
||||
|
||||
#[async_trait]
|
||||
impl Worker for WebhookDeliveryWorker {
|
||||
type Args = WebhookDeliveryArgs;
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"webhook_delivery"
|
||||
}
|
||||
|
||||
async fn perform(&self, db: &PgPool, args: Self::Args) -> SaasResult<()> {
|
||||
let pending = crate::webhook::service::fetch_pending_deliveries(
|
||||
db, MAX_ATTEMPTS, 1,
|
||||
).await?;
|
||||
|
||||
for delivery in pending {
|
||||
if delivery.id != args.delivery_id {
|
||||
continue;
|
||||
}
|
||||
deliver_one(db, &delivery).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 投递所有待处理的 webhooks(由 Scheduler 调用)
|
||||
pub async fn deliver_pending_webhooks(db: &PgPool) -> SaasResult<u32> {
|
||||
let pending = crate::webhook::service::fetch_pending_deliveries(
|
||||
db, MAX_ATTEMPTS, POLL_LIMIT,
|
||||
).await?;
|
||||
|
||||
let count = pending.len() as u32;
|
||||
for delivery in &pending {
|
||||
if let Err(e) = deliver_one(db, delivery).await {
|
||||
tracing::warn!(
|
||||
delivery_id = %delivery.id,
|
||||
url = %delivery.url,
|
||||
"Webhook delivery failed: {}", e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
tracing::info!("Webhook delivery batch: {} pending processed", count);
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 投递单个 webhook
|
||||
async fn deliver_one(
|
||||
db: &PgPool,
|
||||
delivery: &crate::webhook::service::PendingDelivery,
|
||||
) -> SaasResult<()> {
|
||||
let payload_str = serde_json::to_string(&delivery.payload)?;
|
||||
|
||||
// 计算 HMAC-SHA256 签名
|
||||
let signature = compute_hmac_signature(&delivery.secret, &payload_str);
|
||||
|
||||
// 构建 webhook payload envelope
|
||||
let envelope = serde_json::json!({
|
||||
"event": delivery.event,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
"data": delivery.payload,
|
||||
"delivery_id": delivery.id,
|
||||
});
|
||||
let body = serde_json::to_string(&envelope)?;
|
||||
|
||||
// 发送 HTTP POST
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| crate::error::SaasError::Internal(format!("reqwest client 创建失败: {}", e)))?;
|
||||
|
||||
let result = client
|
||||
.post(&delivery.url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Webhook-Signature", format!("sha256={}", signature))
|
||||
.header("X-Webhook-Delivery-ID", &delivery.id)
|
||||
.header("X-Webhook-Event", &delivery.event)
|
||||
.body(body)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16() as i32;
|
||||
let resp_body = resp.text().await.unwrap_or_default();
|
||||
let success = (200..300).contains(&status);
|
||||
let resp_body_truncated = if resp_body.len() > 1024 {
|
||||
&resp_body[..1024]
|
||||
} else {
|
||||
&resp_body
|
||||
};
|
||||
|
||||
crate::webhook::service::record_delivery_result(
|
||||
db,
|
||||
&delivery.id,
|
||||
Some(status),
|
||||
Some(resp_body_truncated),
|
||||
success,
|
||||
).await?;
|
||||
|
||||
if success {
|
||||
tracing::debug!(
|
||||
delivery_id = %delivery.id,
|
||||
url = %delivery.url,
|
||||
status = status,
|
||||
"Webhook delivered successfully"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
delivery_id = %delivery.id,
|
||||
url = %delivery.url,
|
||||
status = status,
|
||||
"Webhook target returned non-2xx status"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
delivery_id = %delivery.id,
|
||||
url = %delivery.url,
|
||||
"Webhook HTTP request failed: {}", e
|
||||
);
|
||||
crate::webhook::service::record_delivery_result(
|
||||
db,
|
||||
&delivery.id,
|
||||
None,
|
||||
Some(&e.to_string()),
|
||||
false,
|
||||
).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 计算 HMAC-SHA256 签名
|
||||
fn compute_hmac_signature(secret: &str, payload: &str) -> String {
|
||||
let decoded_secret = hex::decode(secret).unwrap_or_else(|_| secret.as_bytes().to_vec());
|
||||
let mut mac = HmacSha256::new_from_slice(&decoded_secret)
|
||||
.expect("HMAC can take any key size");
|
||||
mac.update(payload.as_bytes());
|
||||
let result = mac.finalize();
|
||||
hex::encode(result.into_bytes())
|
||||
}
|
||||
Reference in New Issue
Block a user