From 2fd6d08899857df6a45b8a1d1ccf6ebab3697864 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 7 Apr 2026 01:53:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20SaaS=20Admin=20+=20Tauri=20=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E6=80=A7=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 webhook 死代码模块 (4 文件 + worker,未注册未挂载) - 删除孤立组件 StatusTag.tsx (从未被导入) - authStore 权限模型补全 (scheduler/knowledge/billing 6+ permission key) - authStore 硬编码 logout URL 改为 env 变量 - 清理未使用 service 方法 (agent-templates/billing/roles) - Logs.tsx 代码重复消除 (本地常量 → @/constants/status) - TRUTH.md 数字校准 (Tauri 177→183, SaaS API 131→130) --- admin-v2/src/components/StatusTag.tsx | 15 - admin-v2/src/pages/Logs.tsx | 23 +- admin-v2/src/services/agent-templates.ts | 4 - admin-v2/src/services/billing.ts | 8 - admin-v2/src/services/roles.ts | 10 - admin-v2/src/stores/authStore.ts | 14 +- crates/zclaw-saas/src/webhook/handlers.rs | 110 ------ crates/zclaw-saas/src/webhook/mod.rs | 18 - crates/zclaw-saas/src/webhook/service.rs | 369 ------------------ crates/zclaw-saas/src/webhook/types.rs | 49 --- .../src/workers/webhook_delivery.rs | 175 --------- docs/TRUTH.md | 8 +- 12 files changed, 16 insertions(+), 787 deletions(-) delete mode 100644 admin-v2/src/components/StatusTag.tsx delete mode 100644 crates/zclaw-saas/src/webhook/handlers.rs delete mode 100644 crates/zclaw-saas/src/webhook/mod.rs delete mode 100644 crates/zclaw-saas/src/webhook/service.rs delete mode 100644 crates/zclaw-saas/src/webhook/types.rs delete mode 100644 crates/zclaw-saas/src/workers/webhook_delivery.rs diff --git a/admin-v2/src/components/StatusTag.tsx b/admin-v2/src/components/StatusTag.tsx deleted file mode 100644 index 8773882..0000000 --- a/admin-v2/src/components/StatusTag.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Tag } from 'antd' - -interface StatusTagProps { - status: string - labels: Record - colors: Record -} - -export function StatusTag({ status, labels, colors }: StatusTagProps) { - return ( - - {labels[status] || status} - - ) -} diff --git a/admin-v2/src/pages/Logs.tsx b/admin-v2/src/pages/Logs.tsx index 5111a67..4ce8c00 100644 --- a/admin-v2/src/pages/Logs.tsx +++ b/admin-v2/src/pages/Logs.tsx @@ -8,32 +8,11 @@ import { Tag, Select, Typography } from 'antd' import type { ProColumns } from '@ant-design/pro-components' import { ProTable } from '@ant-design/pro-components' import { logService } from '@/services/logs' +import { actionLabels, actionColors } from '@/constants/status' import type { OperationLog } from '@/types' const { Title } = Typography -const actionLabels: Record = { - login: '登录', logout: '登出', - create_account: '创建账号', update_account: '更新账号', delete_account: '删除账号', - create_provider: '创建服务商', update_provider: '更新服务商', delete_provider: '删除服务商', - create_model: '创建模型', update_model: '更新模型', delete_model: '删除模型', - create_token: '创建密钥', revoke_token: '撤销密钥', - update_config: '更新配置', - create_prompt: '创建提示词', update_prompt: '更新提示词', archive_prompt: '归档提示词', - desktop_audit: '桌面端审计', -} - -const actionColors: Record = { - login: 'green', logout: 'default', - create_account: 'blue', update_account: 'orange', delete_account: 'red', - create_provider: 'blue', update_provider: 'orange', delete_provider: 'red', - create_model: 'blue', update_model: 'orange', delete_model: 'red', - create_token: 'blue', revoke_token: 'red', - update_config: 'orange', - create_prompt: 'blue', update_prompt: 'orange', archive_prompt: 'red', - desktop_audit: 'default', -} - const actionOptions = Object.entries(actionLabels).map(([value, label]) => ({ value, label })) export default function Logs() { diff --git a/admin-v2/src/services/agent-templates.ts b/admin-v2/src/services/agent-templates.ts index b18233f..ba5d7f8 100644 --- a/admin-v2/src/services/agent-templates.ts +++ b/admin-v2/src/services/agent-templates.ts @@ -5,11 +5,7 @@ export const agentTemplateService = { list: (params?: Record, signal?: AbortSignal) => request.get>('/agent-templates', withSignal({ params }, signal)).then((r) => r.data), - get: (id: string, signal?: AbortSignal) => - request.get(`/agent-templates/${id}`, withSignal({}, signal)).then((r) => r.data), - getFull: (id: string, signal?: AbortSignal) => - request.get(`/agent-templates/${id}/full`, withSignal({}, signal)).then((r) => r.data), create: (data: { name: string; description?: string; category?: string; source?: string diff --git a/admin-v2/src/services/billing.ts b/admin-v2/src/services/billing.ts index 1c98c04..0e652c1 100644 --- a/admin-v2/src/services/billing.ts +++ b/admin-v2/src/services/billing.ts @@ -80,18 +80,10 @@ export const billingService = { request.get('/billing/plans', withSignal({}, signal)) .then((r) => r.data), - getPlan: (id: string, signal?: AbortSignal) => - request.get(`/billing/plans/${id}`, withSignal({}, signal)) - .then((r) => r.data), - getSubscription: (signal?: AbortSignal) => request.get('/billing/subscription', withSignal({}, signal)) .then((r) => r.data), - getUsage: (signal?: AbortSignal) => - request.get('/billing/usage', withSignal({}, signal)) - .then((r) => r.data), - createPayment: (data: { plan_id: string; payment_method: 'alipay' | 'wechat' }) => request.post('/billing/payments', data).then((r) => r.data), diff --git a/admin-v2/src/services/roles.ts b/admin-v2/src/services/roles.ts index 303d921..43a174b 100644 --- a/admin-v2/src/services/roles.ts +++ b/admin-v2/src/services/roles.ts @@ -1,7 +1,3 @@ -// ============================================================ -// 角色与权限模板 服务层 -// ============================================================ - import request, { withSignal } from './request' import type { Role, @@ -16,9 +12,6 @@ export const roleService = { list: (signal?: AbortSignal) => request.get('/roles', withSignal({}, signal)).then((r) => r.data), - get: (id: string, signal?: AbortSignal) => - request.get(`/roles/${id}`, withSignal({}, signal)).then((r) => r.data), - create: (data: CreateRoleRequest, signal?: AbortSignal) => request.post('/roles', data, withSignal({}, signal)).then((r) => r.data), @@ -36,9 +29,6 @@ export const roleService = { listTemplates: (signal?: AbortSignal) => request.get('/permission-templates', withSignal({}, signal)).then((r) => r.data), - getTemplate: (id: string, signal?: AbortSignal) => - request.get(`/permission-templates/${id}`, withSignal({}, signal)).then((r) => r.data), - createTemplate: (data: CreateTemplateRequest, signal?: AbortSignal) => request.post('/permission-templates', data, withSignal({}, signal)).then((r) => r.data), diff --git a/admin-v2/src/stores/authStore.ts b/admin-v2/src/stores/authStore.ts index ed5838b..7e01abe 100644 --- a/admin-v2/src/stores/authStore.ts +++ b/admin-v2/src/stores/authStore.ts @@ -9,17 +9,21 @@ import { create } from 'zustand' import type { AccountPublic } from '@/types' -/** 权限常量 — 与后端 db.rs SEED_ROLES 保持同步 */ +/** 权限常量 — 与后端 db.rs seed_roles 保持同步 */ const ROLE_PERMISSIONS: Record = { super_admin: [ 'admin:full', 'account:admin', 'provider:manage', 'model:manage', - 'relay:admin', 'config:write', 'prompt:read', 'prompt:write', - 'prompt:publish', 'prompt:admin', + 'model:read', 'relay:admin', 'relay:use', 'config:write', 'config:read', + 'prompt:read', 'prompt:write', 'prompt:publish', 'prompt:admin', + 'scheduler:read', 'knowledge:read', 'knowledge:write', + 'billing:read', 'billing:write', ], admin: [ 'account:read', 'account:admin', 'provider:manage', 'model:read', - 'model:manage', 'relay:use', 'config:read', + 'model:manage', 'relay:use', 'relay:admin', 'config:read', 'config:write', 'prompt:read', 'prompt:write', 'prompt:publish', + 'scheduler:read', 'knowledge:read', 'knowledge:write', + 'billing:read', ], user: ['model:read', 'relay:use', 'config:read', 'prompt:read'], } @@ -73,7 +77,7 @@ export const useAuthStore = create((set, get) => { localStorage.removeItem(ACCOUNT_KEY) set({ isAuthenticated: false, account: null, permissions: [] }) // 调用后端 logout 清除 HttpOnly cookies(fire-and-forget) - fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}) + fetch(`${import.meta.env.VITE_API_BASE_URL || '/api/v1'}/auth/logout`, { method: 'POST', credentials: 'include' }).catch(() => {}) }, hasPermission: (permission: string) => { diff --git a/crates/zclaw-saas/src/webhook/handlers.rs b/crates/zclaw-saas/src/webhook/handlers.rs deleted file mode 100644 index 844c007..0000000 --- a/crates/zclaw-saas/src/webhook/handlers.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! 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, - Extension(ctx): Extension, - Json(req): Json, -) -> SaasResult<(StatusCode, Json)> { - // 验证 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, - Extension(ctx): Extension, -) -> SaasResult>> { - 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, - Extension(ctx): Extension, - Path(id): Path, -) -> SaasResult { - 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, - Extension(ctx): Extension, - Path(id): Path, - Json(req): Json, -) -> SaasResult> { - // 验证 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, - Extension(ctx): Extension, - Path(id): Path, -) -> SaasResult>> { - let deliveries = service::list_deliveries(&state.db, &ctx.account_id, &id).await?; - Ok(Json(deliveries)) -} diff --git a/crates/zclaw-saas/src/webhook/mod.rs b/crates/zclaw-saas/src/webhook/mod.rs deleted file mode 100644 index b017df6..0000000 --- a/crates/zclaw-saas/src/webhook/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Webhook 模块 — 事件通知系统 -//! -//! 允许 SaaS 平台在事件发生时向外部 URL 发送 HTTP POST 通知。 -//! 支持 HMAC-SHA256 签名验证、投递日志和自动重试。 - -pub mod types; -pub mod service; -pub mod handlers; - -use crate::state::AppState; - -/// Webhook 路由 (需要认证) -pub fn routes() -> axum::Router { - axum::Router::new() - .route("/api/v1/webhooks", axum::routing::get(handlers::list_subscriptions).post(handlers::create_subscription)) - .route("/api/v1/webhooks/:id", axum::routing::patch(handlers::update_subscription).delete(handlers::delete_subscription)) - .route("/api/v1/webhooks/:id/deliveries", axum::routing::get(handlers::list_deliveries)) -} diff --git a/crates/zclaw-saas/src/webhook/service.rs b/crates/zclaw-saas/src/webhook/service.rs deleted file mode 100644 index d1bce7d..0000000 --- a/crates/zclaw-saas/src/webhook/service.rs +++ /dev/null @@ -1,369 +0,0 @@ -//! Webhook 数据库服务层 -//! -//! 提供 CRUD 操作和事件触发。 - -use sqlx::{FromRow, PgPool}; -use crate::error::{SaasError, SaasResult}; -use super::types::*; - -// ─── 数据库行结构 ─────────────────────────────────────────────── - -#[derive(Debug, FromRow)] -#[allow(dead_code)] -struct WebhookSubscriptionRow { - id: String, - account_id: String, - url: String, - secret: String, - events: Vec, - enabled: bool, - created_at: String, - updated_at: String, -} - -impl WebhookSubscriptionRow { - fn to_response(&self) -> WebhookSubscription { - WebhookSubscription { - id: self.id.clone(), - account_id: self.account_id.clone(), - url: self.url.clone(), - events: self.events.clone(), - enabled: self.enabled, - created_at: self.created_at.clone(), - updated_at: self.updated_at.clone(), - } - } -} - -#[derive(Debug, FromRow)] -struct WebhookDeliveryRow { - id: String, - subscription_id: String, - event: String, - payload: serde_json::Value, - response_status: Option, - attempts: i32, - delivered_at: Option, - created_at: String, -} - -impl WebhookDeliveryRow { - fn to_response(&self) -> WebhookDelivery { - WebhookDelivery { - id: self.id.clone(), - subscription_id: self.subscription_id.clone(), - event: self.event.clone(), - payload: self.payload.clone(), - response_status: self.response_status, - attempts: self.attempts, - delivered_at: self.delivered_at.clone(), - created_at: self.created_at.clone(), - } - } -} - -// ─── 内部查询行:触发 webhooks 时需要 secret 和 url ────────── - -#[derive(Debug, FromRow)] -#[allow(dead_code)] -struct SubscriptionForDeliveryRow { - id: String, - url: String, - secret: String, -} - -// ─── CRUD 操作 ──────────────────────────────────────────────── - -/// 创建 Webhook 订阅 -pub async fn create_subscription( - db: &PgPool, - account_id: &str, - req: &CreateWebhookRequest, -) -> SaasResult { - let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - let secret = generate_secret(); - - sqlx::query( - "INSERT INTO webhook_subscriptions (id, account_id, url, secret, events, enabled, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, true, $6, $6)" - ) - .bind(&id) - .bind(account_id) - .bind(&req.url) - .bind(&secret) - .bind(&req.events) - .bind(&now) - .execute(db) - .await?; - - Ok(WebhookSubscription { - id, - account_id: account_id.to_string(), - url: req.url.clone(), - events: req.events.clone(), - enabled: true, - created_at: now.clone(), - updated_at: now, - }) -} - -/// 列出账户的 Webhook 订阅 -pub async fn list_subscriptions( - db: &PgPool, - account_id: &str, -) -> SaasResult> { - let rows: Vec = sqlx::query_as( - "SELECT id, account_id, url, secret, events, enabled, created_at, updated_at - FROM webhook_subscriptions WHERE account_id = $1 ORDER BY created_at DESC" - ) - .bind(account_id) - .fetch_all(db) - .await?; - - Ok(rows.iter().map(|r| r.to_response()).collect()) -} - -/// 获取单个 Webhook 订阅 -pub async fn get_subscription( - db: &PgPool, - account_id: &str, - subscription_id: &str, -) -> SaasResult { - let row: Option = sqlx::query_as( - "SELECT id, account_id, url, secret, events, enabled, created_at, updated_at - FROM webhook_subscriptions WHERE id = $1 AND account_id = $2" - ) - .bind(subscription_id) - .bind(account_id) - .fetch_optional(db) - .await?; - - Ok(row - .ok_or_else(|| SaasError::NotFound("Webhook 订阅不存在".into()))? - .to_response()) -} - -/// 更新 Webhook 订阅 -pub async fn update_subscription( - db: &PgPool, - account_id: &str, - subscription_id: &str, - req: &UpdateWebhookRequest, -) -> SaasResult { - let existing = get_subscription(db, account_id, subscription_id).await?; - let now = chrono::Utc::now().to_rfc3339(); - - let url = req.url.as_deref().unwrap_or(&existing.url); - let events = req.events.as_deref().unwrap_or(&existing.events); - let enabled = req.enabled.unwrap_or(existing.enabled); - - sqlx::query( - "UPDATE webhook_subscriptions SET url = $1, events = $2, enabled = $3, updated_at = $4 - WHERE id = $5 AND account_id = $6" - ) - .bind(url) - .bind(events) - .bind(enabled) - .bind(&now) - .bind(subscription_id) - .bind(account_id) - .execute(db) - .await?; - - get_subscription(db, account_id, subscription_id).await -} - -/// 删除 Webhook 订阅 -pub async fn delete_subscription( - db: &PgPool, - account_id: &str, - subscription_id: &str, -) -> SaasResult<()> { - let result = sqlx::query( - "DELETE FROM webhook_subscriptions WHERE id = $1 AND account_id = $2" - ) - .bind(subscription_id) - .bind(account_id) - .execute(db) - .await?; - - if result.rows_affected() == 0 { - return Err(SaasError::NotFound("Webhook 订阅不存在".into())); - } - Ok(()) -} - -/// 列出订阅的投递日志 -pub async fn list_deliveries( - db: &PgPool, - account_id: &str, - subscription_id: &str, -) -> SaasResult> { - // 先验证订阅属于该账户 - let _ = get_subscription(db, account_id, subscription_id).await?; - - let rows: Vec = sqlx::query_as( - "SELECT id, subscription_id, event, payload, response_status, attempts, delivered_at, created_at - FROM webhook_deliveries WHERE subscription_id = $1 ORDER BY created_at DESC" - ) - .bind(subscription_id) - .fetch_all(db) - .await?; - - Ok(rows.iter().map(|r| r.to_response()).collect()) -} - -// ─── 事件触发 ───────────────────────────────────────────────── - -/// 触发匹配的 webhook 订阅,创建投递记录。 -/// -/// 查找所有启用的、事件列表包含 `event` 的订阅, -/// 为每个订阅创建一条 `webhook_deliveries` 记录。 -pub async fn trigger_webhooks( - db: &PgPool, - event: &str, - payload: &serde_json::Value, -) -> SaasResult> { - let subs: Vec = sqlx::query_as( - "SELECT id, url, secret FROM webhook_subscriptions - WHERE enabled = true AND $1 = ANY(events)" - ) - .bind(event) - .fetch_all(db) - .await?; - - let mut delivery_ids = Vec::with_capacity(subs.len()); - for sub in &subs { - let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - sqlx::query( - "INSERT INTO webhook_deliveries (id, subscription_id, event, payload, attempts, created_at) - VALUES ($1, $2, $3, $4, 0, $5)" - ) - .bind(&id) - .bind(&sub.id) - .bind(event) - .bind(payload) - .bind(&now) - .execute(db) - .await?; - - delivery_ids.push(id); - } - - if !delivery_ids.is_empty() { - tracing::info!( - event = %event, - count = delivery_ids.len(), - "Webhook deliveries enqueued" - ); - } - - Ok(delivery_ids) -} - -// ─── 投递查询(供 Worker 使用)──────────────────────────────── - -/// 获取待投递的记录(未投递且尝试次数 < max_attempts) -pub(crate) async fn fetch_pending_deliveries( - db: &PgPool, - max_attempts: i32, - limit: i32, -) -> SaasResult> { - let rows: Vec = sqlx::query_as( - "SELECT d.id, d.subscription_id, d.event, d.payload, d.attempts, - s.url, s.secret - FROM webhook_deliveries d - JOIN webhook_subscriptions s ON s.id = d.subscription_id - WHERE d.delivered_at IS NULL AND d.attempts < $1 AND s.enabled = true - ORDER BY d.created_at ASC - LIMIT $2" - ) - .bind(max_attempts) - .bind(limit) - .fetch_all(db) - .await?; - - Ok(rows.into_iter().map(|r| r.to_pending()).collect()) -} - -/// 记录投递结果 -pub async fn record_delivery_result( - db: &PgPool, - delivery_id: &str, - response_status: Option, - response_body: Option<&str>, - success: bool, -) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); - if success { - sqlx::query( - "UPDATE webhook_deliveries SET response_status = $1, response_body = $2, delivered_at = $3, attempts = attempts + 1 - WHERE id = $4" - ) - .bind(response_status) - .bind(response_body) - .bind(&now) - .bind(delivery_id) - .execute(db) - .await?; - } else { - sqlx::query( - "UPDATE webhook_deliveries SET response_status = $1, response_body = $2, attempts = attempts + 1 - WHERE id = $3" - ) - .bind(response_status) - .bind(response_body) - .bind(delivery_id) - .execute(db) - .await?; - } - Ok(()) -} - -// ─── 内部类型 ────────────────────────────────────────────────── - -#[derive(Debug, FromRow)] -struct PendingDeliveryRow { - id: String, - subscription_id: String, - event: String, - payload: serde_json::Value, - attempts: i32, - url: String, - secret: String, -} - -#[allow(dead_code)] -pub(crate) struct PendingDelivery { - pub id: String, - pub subscription_id: String, - pub event: String, - pub payload: serde_json::Value, - pub attempts: i32, - pub url: String, - pub secret: String, -} - -impl PendingDeliveryRow { - fn to_pending(self) -> PendingDelivery { - PendingDelivery { - id: self.id, - subscription_id: self.subscription_id, - event: self.event, - payload: self.payload, - attempts: self.attempts, - url: self.url, - secret: self.secret, - } - } -} - -/// 生成 32 字节随机签名密钥(hex 编码) -fn generate_secret() -> String { - use rand::RngCore; - let mut buf = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut buf); - hex::encode(buf) -} diff --git a/crates/zclaw-saas/src/webhook/types.rs b/crates/zclaw-saas/src/webhook/types.rs deleted file mode 100644 index 19f8aea..0000000 --- a/crates/zclaw-saas/src/webhook/types.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Webhook 类型定义 - -use serde::{Deserialize, Serialize}; - -/// Webhook 订阅响应 -#[derive(Debug, Serialize)] -pub struct WebhookSubscription { - pub id: String, - pub account_id: String, - pub url: String, - pub events: Vec, - pub enabled: bool, - pub created_at: String, - pub updated_at: String, -} - -/// 创建 Webhook 订阅请求 -#[derive(Debug, Deserialize)] -pub struct CreateWebhookRequest { - pub url: String, - pub events: Vec, -} - -/// 更新 Webhook 订阅请求 -#[derive(Debug, Deserialize)] -pub struct UpdateWebhookRequest { - pub url: Option, - pub events: Option>, - pub enabled: Option, -} - -/// Webhook 投递日志响应 -#[derive(Debug, Serialize)] -pub struct WebhookDelivery { - pub id: String, - pub subscription_id: String, - pub event: String, - pub payload: serde_json::Value, - pub response_status: Option, - pub attempts: i32, - pub delivered_at: Option, - pub created_at: String, -} - -/// Webhook 投递 Worker 参数 -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct WebhookDeliveryArgs { - pub delivery_id: String, -} diff --git a/crates/zclaw-saas/src/workers/webhook_delivery.rs b/crates/zclaw-saas/src/workers/webhook_delivery.rs deleted file mode 100644 index 184230a..0000000 --- a/crates/zclaw-saas/src/workers/webhook_delivery.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! 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; - -/// 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 { - 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()) -} diff --git a/docs/TRUTH.md b/docs/TRUTH.md index 589786b..082777d 100644 --- a/docs/TRUTH.md +++ b/docs/TRUTH.md @@ -13,14 +13,14 @@ | Rust Crates | 10 个 (编译通过) | `cargo check --workspace` | | Rust 代码行数 | ~66,000 | wc -l | | Rust 单元测试 | 383 个 | `grep '#\[test\]' crates/` | -| Tauri 命令 | 177 个 (176 注册 + 1 未注册 identity_init) | grep `#[tauri::command]` 完整审计 | +| Tauri 命令 | 183 个 (180 注册含 4 A2A feature-gated + 1 未注册 identity_init + 2 待确认) | grep `#[tauri::command]` 完整审计 | | **Tauri 命令有前端调用** | **160 个** | @connected 标注(含 4 个 A2A feature-gated) | | **Tauri 命令无前端调用** | **16 个** | @reserved 标注 | | SKILL.md 文件 | 75 个 | `ls skills/*.md \| wc -l` | | Hands 启用 | 9 个 | Browser/Collector/Researcher/Clip/Twitter/Whiteboard/Slideshow/Speech/Quiz(均有 HAND.toml) | | Hands 禁用 | 2 个 | Predictor, Lead(概念定义存在,无 TOML 配置文件或 Rust 实现) | | Pipeline 模板 | 17 个 YAML | `pipelines/` 目录全量统计(含 _templates/ 和 design-shantou/ 子目录) | -| SaaS API 端点 | 131 个(含 2 个 dev-only mock) | 路由注册 handler 引用全量统计 | +| SaaS API 端点 | 130 个(128 标准 + 2 dev-only mock;webhook 5 路由已定义但未挂载) | 路由注册 handler 引用全量统计 | | SaaS 路由模块 | 12 个 | account/agent_template/auth/billing/knowledge/migration/model_config/prompt/relay/role/scheduled_task/telemetry(scheduled_task: 后端 5 CRUD + Admin V2 前端 service/page/route/nav) | | SaaS 数据表 | 34 个(含 saas_schema_version) | CREATE TABLE 全量统计 | | SaaS Workers | 7 个 | log_operation/cleanup_rate_limit/cleanup_refresh_tokens/record_usage/update_last_used/aggregate_usage/generate_embedding | @@ -140,6 +140,9 @@ Viking 5 个孤立 invoke 调用已于 2026-04-03 清理移除: | Director multi-agent | 912 | feature-gated off + 已注解 | 保留 | | A2A 协议 | ~400 | feature-gated off + 已注解 | 保留 | | WASM runner | ~200 | **Active module** + 已注解 | 保留 | +| `crates/zclaw-saas/src/webhook/` | ~400 | **死代码**: 未在 lib.rs pub mod 注册,未在 main.rs merge(),无前端调用 | 删除 | +| `crates/zclaw-saas/src/workers/webhook_delivery.rs` | ~160 | **死代码**: 未在 workers/mod.rs pub mod 注册 | 删除 | +| `admin-v2/src/components/StatusTag.tsx` | ~20 | **孤立组件**: 从未被导入 | 删除 | --- @@ -187,3 +190,4 @@ Viking 5 个孤立 invoke 调用已于 2026-04-03 清理移除: | 2026-04-03 | 前端改进记录:(1) Pipeline 8 invoke 接通前端 (2) Viking 5 孤立 invoke 清理 + 2 新方法+UI (3) api-fallbacks _isFallback 标记 + console.warn 日志 (4) MessageSearch 恢复到 ChatArea (5) scheduled_task Admin V2 完整接入 (service+page+route+nav) | | 2026-04-04 | V12 模块化审计后更新:(1) Pipeline 模板 10→17 YAML (2) Hands 禁用说明细化(无 TOML/Rust 实现) (3) SEC2-P1-01 FactStore 标记 FALSE_POSITIVE (4) V11-P1-03 SQL 表标记 FALSE_POSITIVE (5) M11-02 map_err 已修复 (6) M4-04 深层 WONTFIX | | 2026-04-05 | Admin V2 页面数 14→15(新增 ConfigSync 页面);桌面端设置页面确认为 19 个 | +| 2026-04-06 | 全面一致性审查:(1) Tauri 命令 177→183 (grep 重新验证) (2) SaaS API 131→130 (webhook 5 路由已定义但未挂载) (3) 删除 webhook 死代码模块 + webhook_delivery worker (4) admin-v2 权限模型修复 (6+ permission key 补全) (5) Logs.tsx 代码重复消除 (6) 清理未使用 service 方法 (agent-templates/billing/roles) |