fix: SaaS Admin + Tauri 一致性审查修复
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- 删除 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)
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
import { Tag } from 'antd'
|
||||
|
||||
interface StatusTagProps {
|
||||
status: string
|
||||
labels: Record<string, string>
|
||||
colors: Record<string, string>
|
||||
}
|
||||
|
||||
export function StatusTag({ status, labels, colors }: StatusTagProps) {
|
||||
return (
|
||||
<Tag color={colors[status] || 'default'}>
|
||||
{labels[status] || status}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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() {
|
||||
|
||||
@@ -5,11 +5,7 @@ export const agentTemplateService = {
|
||||
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||
request.get<PaginatedResponse<AgentTemplate>>('/agent-templates', withSignal({ params }, signal)).then((r) => r.data),
|
||||
|
||||
get: (id: string, signal?: AbortSignal) =>
|
||||
request.get<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
getFull: (id: string, signal?: AbortSignal) =>
|
||||
request.get<AgentTemplate>(`/agent-templates/${id}/full`, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
create: (data: {
|
||||
name: string; description?: string; category?: string; source?: string
|
||||
|
||||
@@ -80,18 +80,10 @@ export const billingService = {
|
||||
request.get<BillingPlan[]>('/billing/plans', withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
getPlan: (id: string, signal?: AbortSignal) =>
|
||||
request.get<BillingPlan>(`/billing/plans/${id}`, withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
getSubscription: (signal?: AbortSignal) =>
|
||||
request.get<SubscriptionInfo>('/billing/subscription', withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
getUsage: (signal?: AbortSignal) =>
|
||||
request.get<UsageQuota>('/billing/usage', withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
createPayment: (data: { plan_id: string; payment_method: 'alipay' | 'wechat' }) =>
|
||||
request.post<PaymentResult>('/billing/payments', data).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
// ============================================================
|
||||
// 角色与权限模板 服务层
|
||||
// ============================================================
|
||||
|
||||
import request, { withSignal } from './request'
|
||||
import type {
|
||||
Role,
|
||||
@@ -16,9 +12,6 @@ export const roleService = {
|
||||
list: (signal?: AbortSignal) =>
|
||||
request.get<Role[]>('/roles', withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
get: (id: string, signal?: AbortSignal) =>
|
||||
request.get<Role>(`/roles/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
create: (data: CreateRoleRequest, signal?: AbortSignal) =>
|
||||
request.post<Role>('/roles', data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
@@ -36,9 +29,6 @@ export const roleService = {
|
||||
listTemplates: (signal?: AbortSignal) =>
|
||||
request.get<PermissionTemplate[]>('/permission-templates', withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
getTemplate: (id: string, signal?: AbortSignal) =>
|
||||
request.get<PermissionTemplate>(`/permission-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
createTemplate: (data: CreateTemplateRequest, signal?: AbortSignal) =>
|
||||
request.post<PermissionTemplate>('/permission-templates', data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
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<AuthState>((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) => {
|
||||
|
||||
@@ -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<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))
|
||||
}
|
||||
@@ -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<AppState> {
|
||||
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))
|
||||
}
|
||||
@@ -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<String>,
|
||||
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<i32>,
|
||||
attempts: i32,
|
||||
delivered_at: Option<String>,
|
||||
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<WebhookSubscription> {
|
||||
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<Vec<WebhookSubscription>> {
|
||||
let rows: Vec<WebhookSubscriptionRow> = 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<WebhookSubscription> {
|
||||
let row: Option<WebhookSubscriptionRow> = 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<WebhookSubscription> {
|
||||
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<Vec<WebhookDelivery>> {
|
||||
// 先验证订阅属于该账户
|
||||
let _ = get_subscription(db, account_id, subscription_id).await?;
|
||||
|
||||
let rows: Vec<WebhookDeliveryRow> = 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<Vec<String>> {
|
||||
let subs: Vec<SubscriptionForDeliveryRow> = 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<Vec<PendingDelivery>> {
|
||||
let rows: Vec<PendingDeliveryRow> = 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<i32>,
|
||||
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)
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 创建 Webhook 订阅请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateWebhookRequest {
|
||||
pub url: String,
|
||||
pub events: Vec<String>,
|
||||
}
|
||||
|
||||
/// 更新 Webhook 订阅请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateWebhookRequest {
|
||||
pub url: Option<String>,
|
||||
pub events: Option<Vec<String>>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// 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<i32>,
|
||||
pub attempts: i32,
|
||||
pub delivered_at: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Webhook 投递 Worker 参数
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct WebhookDeliveryArgs {
|
||||
pub delivery_id: String,
|
||||
}
|
||||
@@ -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<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())
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user