Files
zclaw_openfang/crates/zclaw-saas/src/webhook/handlers.rs
iven 5eeabd1f30 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.
2026-04-03 23:01:49 +08:00

111 lines
3.7 KiB
Rust

//! Webhook HTTP 处理器
//!
//! 提供 Webhook 订阅的 CRUD 和投递日志查询。
use axum::{
extract::{Extension, Path, State},
http::StatusCode,
Json,
};
use crate::auth::types::AuthContext;
use crate::error::SaasResult;
use crate::state::AppState;
use super::{service, types::*};
/// POST /api/v1/webhooks — 创建 Webhook 订阅
// @connected
pub async fn create_subscription(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<CreateWebhookRequest>,
) -> SaasResult<(StatusCode, Json<WebhookSubscription>)> {
// 验证 URL 格式
if req.url.is_empty() {
return Err(crate::error::SaasError::InvalidInput("URL 不能为空".into()));
}
if url::Url::parse(&req.url).is_err() {
return Err(crate::error::SaasError::InvalidInput("URL 格式无效".into()));
}
// 验证事件列表不为空
if req.events.is_empty() {
return Err(crate::error::SaasError::InvalidInput(
"事件列表不能为空,至少需要一个事件".into(),
));
}
// 验证每个事件名称格式 (namespace.action)
for ev in &req.events {
if !ev.contains('.') || ev.starts_with('.') || ev.ends_with('.') {
return Err(crate::error::SaasError::InvalidInput(
format!("事件名称 '{}' 格式无效,应为 namespace.action 格式", ev),
));
}
}
let sub = service::create_subscription(&state.db, &ctx.account_id, &req).await?;
Ok((StatusCode::CREATED, Json(sub)))
}
/// GET /api/v1/webhooks — 列出 Webhook 订阅
// @connected
pub async fn list_subscriptions(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<Vec<WebhookSubscription>>> {
let subs = service::list_subscriptions(&state.db, &ctx.account_id).await?;
Ok(Json(subs))
}
/// DELETE /api/v1/webhooks/:id — 删除 Webhook 订阅
// @connected
pub async fn delete_subscription(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(id): Path<String>,
) -> SaasResult<StatusCode> {
service::delete_subscription(&state.db, &ctx.account_id, &id).await?;
Ok(StatusCode::NO_CONTENT)
}
/// PATCH /api/v1/webhooks/:id — 更新 Webhook 订阅
// @connected
pub async fn update_subscription(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(id): Path<String>,
Json(req): Json<UpdateWebhookRequest>,
) -> SaasResult<Json<WebhookSubscription>> {
// 验证 URL 格式(如果提供了)
if let Some(ref url) = req.url {
if url.is_empty() {
return Err(crate::error::SaasError::InvalidInput("URL 不能为空".into()));
}
if url::Url::parse(url).is_err() {
return Err(crate::error::SaasError::InvalidInput("URL 格式无效".into()));
}
}
// 验证事件名称格式(如果提供了)
if let Some(ref events) = req.events {
for ev in events {
if !ev.contains('.') || ev.starts_with('.') || ev.ends_with('.') {
return Err(crate::error::SaasError::InvalidInput(
format!("事件名称 '{}' 格式无效,应为 namespace.action 格式", ev),
));
}
}
}
let sub = service::update_subscription(&state.db, &ctx.account_id, &id, &req).await?;
Ok(Json(sub))
}
/// GET /api/v1/webhooks/:id/deliveries — 列出投递日志
// @connected
pub async fn list_deliveries(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(id): Path<String>,
) -> SaasResult<Json<Vec<WebhookDelivery>>> {
let deliveries = service::list_deliveries(&state.db, &ctx.account_id, &id).await?;
Ok(Json(deliveries))
}