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:
iven
2026-04-03 23:01:49 +08:00
parent 1c99e5f3a3
commit 5eeabd1f30
6 changed files with 749 additions and 0 deletions

View 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())
}