Implement webhook
This commit is contained in:
@@ -26,6 +26,7 @@ import uvicorn
|
||||
from db.session import sync_default_tools, sync_interface_definitions
|
||||
from services.knowledge import recover_interrupted_documents
|
||||
from services.post_call.worker import recover_interrupted_analyses, run_analysis_worker
|
||||
from services.webhooks.worker import recover_interrupted_webhooks, run_webhook_worker
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
@@ -39,6 +40,7 @@ from routes import (
|
||||
mcp_servers,
|
||||
model_registry,
|
||||
node_types,
|
||||
site_settings,
|
||||
tools,
|
||||
voice_webrtc,
|
||||
voice_ws,
|
||||
@@ -51,14 +53,21 @@ async def lifespan(_app: FastAPI):
|
||||
await sync_default_tools()
|
||||
await recover_interrupted_documents()
|
||||
await recover_interrupted_analyses()
|
||||
await recover_interrupted_webhooks()
|
||||
analysis_worker = asyncio.create_task(
|
||||
run_analysis_worker(), name="post-call-analysis-worker"
|
||||
)
|
||||
webhook_worker = asyncio.create_task(
|
||||
run_webhook_worker(), name="analysis-webhook-worker"
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
analysis_worker.cancel()
|
||||
await asyncio.gather(analysis_worker, return_exceptions=True)
|
||||
webhook_worker.cancel()
|
||||
await asyncio.gather(
|
||||
analysis_worker, webhook_worker, return_exceptions=True
|
||||
)
|
||||
await voice_webrtc.shutdown_active_sessions()
|
||||
|
||||
|
||||
@@ -81,6 +90,7 @@ app.include_router(knowledge_bases.router)
|
||||
app.include_router(mcp_servers.router)
|
||||
app.include_router(model_registry.router)
|
||||
app.include_router(node_types.router)
|
||||
app.include_router(site_settings.router)
|
||||
app.include_router(tools.router)
|
||||
app.include_router(voice_webrtc.router)
|
||||
app.include_router(voice_ws.router)
|
||||
|
||||
@@ -374,3 +374,51 @@ class ConversationArtifact(Base):
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
extra: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
|
||||
|
||||
class SiteSetting(Base):
|
||||
"""Deployment-wide settings, split into public values and write-only secrets."""
|
||||
|
||||
__tablename__ = "site_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
secrets: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class WebhookDelivery(Base):
|
||||
"""Immutable event snapshot plus its asynchronous delivery state."""
|
||||
|
||||
__tablename__ = "webhook_deliveries"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
event_id: Mapped[str] = mapped_column(String(40), unique=True)
|
||||
event_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||
conversation_id: Mapped[str | None] = mapped_column(
|
||||
String(40),
|
||||
ForeignKey("conversation_sessions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
payload: Mapped[dict] = mapped_column(JSONB)
|
||||
url: Mapped[str] = mapped_column(String(2048))
|
||||
secrets: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
status: Mapped[str] = mapped_column(String(16), index=True, default="pending")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
last_status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
last_error: Mapped[str] = mapped_column(String(2048), default="")
|
||||
delivered_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""add global analysis webhook settings and delivery queue
|
||||
|
||||
Revision ID: 20260807_0015
|
||||
Revises: 20260807_0014
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "20260807_0015"
|
||||
down_revision: str | Sequence[str] | None = "20260807_0014"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"site_settings",
|
||||
sa.Column("key", sa.String(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"value",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"secrets",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("key"),
|
||||
)
|
||||
op.create_table(
|
||||
"webhook_deliveries",
|
||||
sa.Column("id", sa.String(length=40), nullable=False),
|
||||
sa.Column("event_id", sa.String(length=40), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=128), nullable=False),
|
||||
sa.Column("conversation_id", sa.String(length=40), nullable=True),
|
||||
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("url", sa.String(length=2048), nullable=False),
|
||||
sa.Column(
|
||||
"secrets",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("status", sa.String(length=16), server_default="pending", nullable=False),
|
||||
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_status_code", sa.Integer(), nullable=True),
|
||||
sa.Column("last_error", sa.String(length=2048), server_default="", nullable=False),
|
||||
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["conversation_id"], ["conversation_sessions.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("event_id"),
|
||||
)
|
||||
op.create_index("ix_webhook_deliveries_event_type", "webhook_deliveries", ["event_type"])
|
||||
op.create_index("ix_webhook_deliveries_conversation_id", "webhook_deliveries", ["conversation_id"])
|
||||
op.create_index("ix_webhook_deliveries_status", "webhook_deliveries", ["status"])
|
||||
op.create_index("ix_webhook_deliveries_next_attempt_at", "webhook_deliveries", ["next_attempt_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_webhook_deliveries_next_attempt_at", table_name="webhook_deliveries")
|
||||
op.drop_index("ix_webhook_deliveries_status", table_name="webhook_deliveries")
|
||||
op.drop_index("ix_webhook_deliveries_conversation_id", table_name="webhook_deliveries")
|
||||
op.drop_index("ix_webhook_deliveries_event_type", table_name="webhook_deliveries")
|
||||
op.drop_table("webhook_deliveries")
|
||||
op.drop_table("site_settings")
|
||||
126
backend/routes/site_settings.py
Normal file
126
backend/routes/site_settings.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Administration API for deployment-wide settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import uuid
|
||||
|
||||
from db.models import SiteSetting
|
||||
from db.session import get_session
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from schemas import (
|
||||
AnalysisWebhookSettingOut,
|
||||
AnalysisWebhookSettingUpdate,
|
||||
AnalysisWebhookTestIn,
|
||||
AnalysisWebhookTestOut,
|
||||
)
|
||||
from services.auth import require_admin
|
||||
from services.webhooks.delivery import deliver_webhook
|
||||
from services.webhooks.events import ANALYSIS_WEBHOOK_KEY
|
||||
from services.webhooks.security import UnsafeWebhookUrl, validate_webhook_url
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/settings",
|
||||
tags=["site-settings"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
def _setting_out(row: SiteSetting | None) -> AnalysisWebhookSettingOut:
|
||||
values = dict(row.value or {}) if row else {}
|
||||
secrets = dict(row.secrets or {}) if row else {}
|
||||
return AnalysisWebhookSettingOut(
|
||||
enabled=bool(values.get("enabled")),
|
||||
url=str(values.get("url") or ""),
|
||||
secret_configured=bool(secrets.get("secret")),
|
||||
updated_at=row.updated_at if row else None,
|
||||
)
|
||||
|
||||
|
||||
async def _safe_url_or_422(url: str) -> str:
|
||||
try:
|
||||
return await validate_webhook_url(url)
|
||||
except UnsafeWebhookUrl as exc:
|
||||
raise HTTPException(422, str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/analysis-webhook", response_model=AnalysisWebhookSettingOut)
|
||||
async def get_analysis_webhook(session: AsyncSession = Depends(get_session)):
|
||||
return _setting_out(await session.get(SiteSetting, ANALYSIS_WEBHOOK_KEY))
|
||||
|
||||
|
||||
@router.put("/analysis-webhook", response_model=AnalysisWebhookSettingOut)
|
||||
async def update_analysis_webhook(
|
||||
body: AnalysisWebhookSettingUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
row = await session.get(SiteSetting, ANALYSIS_WEBHOOK_KEY)
|
||||
stored_secrets = dict(row.secrets or {}) if row else {}
|
||||
incoming_secret = (body.secret or "").strip()
|
||||
if body.clear_secret:
|
||||
stored_secrets.pop("secret", None)
|
||||
if incoming_secret:
|
||||
stored_secrets["secret"] = incoming_secret
|
||||
|
||||
if body.url:
|
||||
await _safe_url_or_422(body.url)
|
||||
if body.enabled and not stored_secrets.get("secret"):
|
||||
raise HTTPException(422, "启用 Webhook 时必须配置签名密钥")
|
||||
|
||||
if row is None:
|
||||
row = SiteSetting(key=ANALYSIS_WEBHOOK_KEY)
|
||||
session.add(row)
|
||||
row.value = {"enabled": body.enabled, "url": body.url}
|
||||
row.secrets = stored_secrets
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _setting_out(row)
|
||||
|
||||
|
||||
@router.post("/analysis-webhook/test", response_model=AnalysisWebhookTestOut)
|
||||
async def test_analysis_webhook(
|
||||
body: AnalysisWebhookTestIn,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
url = await _safe_url_or_422(body.url)
|
||||
row = await session.get(SiteSetting, ANALYSIS_WEBHOOK_KEY)
|
||||
stored_secret = str((row.secrets or {}).get("secret") or "") if row else ""
|
||||
secret = (body.secret or "").strip() or stored_secret
|
||||
if not secret:
|
||||
raise HTTPException(422, "请先填写或保存签名密钥")
|
||||
|
||||
event_id = f"evt_{uuid.uuid4().hex}"
|
||||
payload = {
|
||||
"id": event_id,
|
||||
"event": "call.analysis.completed",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"data": {
|
||||
"conversationId": "conv_test_001",
|
||||
"assistantId": "asst_test_001",
|
||||
"assistantName": "Webhook 测试助手",
|
||||
"analysis": {
|
||||
"customer_intent": "high",
|
||||
"need_follow_up": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
result = await deliver_webhook(
|
||||
url=url,
|
||||
secret=secret,
|
||||
event_id=event_id,
|
||||
payload=payload,
|
||||
)
|
||||
message = (
|
||||
f"测试事件已送达,目标返回 HTTP {result.status_code}。"
|
||||
if result.ok
|
||||
else result.error or "测试事件发送失败。"
|
||||
)
|
||||
return AnalysisWebhookTestOut(
|
||||
ok=result.ok,
|
||||
status_code=result.status_code,
|
||||
latency_ms=result.latency_ms,
|
||||
message=message,
|
||||
payload=payload,
|
||||
)
|
||||
@@ -566,3 +566,38 @@ class ConversationListOut(CamelModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# ---------- 站点级 Webhook ----------
|
||||
class AnalysisWebhookSettingOut(CamelModel):
|
||||
enabled: bool = False
|
||||
url: str = ""
|
||||
secret_configured: bool = False
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class AnalysisWebhookSettingUpdate(CamelModel):
|
||||
enabled: bool = False
|
||||
url: str = Field(default="", max_length=2048)
|
||||
secret: str | None = Field(default=None, max_length=512)
|
||||
clear_secret: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_enabled_url(self):
|
||||
self.url = self.url.strip()
|
||||
if self.enabled and not self.url:
|
||||
raise ValueError("启用 Webhook 时必须填写 URL")
|
||||
return self
|
||||
|
||||
|
||||
class AnalysisWebhookTestIn(CamelModel):
|
||||
url: str = Field(min_length=1, max_length=2048)
|
||||
secret: str | None = Field(default=None, max_length=512)
|
||||
|
||||
|
||||
class AnalysisWebhookTestOut(CamelModel):
|
||||
ok: bool
|
||||
status_code: int | None = None
|
||||
latency_ms: int | None = None
|
||||
message: str
|
||||
payload: dict[str, Any]
|
||||
|
||||
@@ -10,6 +10,7 @@ import httpx
|
||||
from db.models import ConversationMessage, ConversationSession, ModelResource
|
||||
from db.session import SessionLocal
|
||||
from sqlalchemy import select
|
||||
from services.webhooks.events import enqueue_analysis_completed
|
||||
|
||||
|
||||
ANALYSIS_TIMEOUT_SECONDS = 60.0
|
||||
@@ -184,6 +185,7 @@ async def analyze_conversation(conversation_id: str) -> None:
|
||||
conversation.analysis_data = data
|
||||
conversation.analysis_status = "completed"
|
||||
conversation.analysis_error = ""
|
||||
await enqueue_analysis_completed(session, conversation, result)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
async with SessionLocal() as session:
|
||||
@@ -192,4 +194,3 @@ async def analyze_conversation(conversation_id: str) -> None:
|
||||
conversation.analysis_status = "failed"
|
||||
conversation.analysis_error = str(exc)[:2048]
|
||||
await session.commit()
|
||||
|
||||
|
||||
1
backend/services/webhooks/__init__.py
Normal file
1
backend/services/webhooks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Global webhook delivery helpers."""
|
||||
86
backend/services/webhooks/delivery.py
Normal file
86
backend/services/webhooks/delivery.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Serialize, sign and send one webhook event."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from services.webhooks.security import validate_webhook_url
|
||||
|
||||
|
||||
WEBHOOK_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliveryResult:
|
||||
ok: bool
|
||||
status_code: int | None
|
||||
latency_ms: int
|
||||
error: str = ""
|
||||
|
||||
|
||||
def canonical_json(payload: dict[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def webhook_signature(secret: str, timestamp: str, body: bytes) -> str:
|
||||
signed = timestamp.encode("ascii") + b"." + body
|
||||
digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
|
||||
return f"v1={digest}"
|
||||
|
||||
|
||||
def should_retry(status_code: int | None) -> bool:
|
||||
return status_code is None or status_code in {408, 429} or status_code >= 500
|
||||
|
||||
|
||||
async def deliver_webhook(
|
||||
*,
|
||||
url: str,
|
||||
secret: str,
|
||||
event_id: str,
|
||||
payload: dict[str, Any],
|
||||
) -> DeliveryResult:
|
||||
"""Deliver without following redirects; callers decide whether to retry."""
|
||||
safe_url = await validate_webhook_url(url)
|
||||
body = canonical_json(payload)
|
||||
timestamp = str(int(time.time()))
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-AIVideo-Event-Id": event_id,
|
||||
"X-AIVideo-Timestamp": timestamp,
|
||||
"X-AIVideo-Signature": webhook_signature(secret, timestamp, body),
|
||||
}
|
||||
started = time.monotonic()
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=WEBHOOK_TIMEOUT_SECONDS,
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
response = await client.post(safe_url, content=body, headers=headers)
|
||||
latency_ms = round((time.monotonic() - started) * 1000)
|
||||
if 200 <= response.status_code < 300:
|
||||
return DeliveryResult(True, response.status_code, latency_ms)
|
||||
return DeliveryResult(
|
||||
False,
|
||||
response.status_code,
|
||||
latency_ms,
|
||||
f"目标服务返回 HTTP {response.status_code}",
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return DeliveryResult(
|
||||
False,
|
||||
None,
|
||||
round((time.monotonic() - started) * 1000),
|
||||
str(exc)[:2048],
|
||||
)
|
||||
69
backend/services/webhooks/events.py
Normal file
69
backend/services/webhooks/events.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Build durable webhook events in the same transaction as domain updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from db.models import ConversationSession, SiteSetting, WebhookDelivery
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
ANALYSIS_WEBHOOK_KEY = "analysis_webhook"
|
||||
ANALYSIS_COMPLETED_EVENT = "call.analysis.completed"
|
||||
|
||||
|
||||
def analysis_completed_payload(
|
||||
conversation: ConversationSession,
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
event_id: str,
|
||||
timestamp: datetime,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": event_id,
|
||||
"event": ANALYSIS_COMPLETED_EVENT,
|
||||
"timestamp": timestamp.isoformat(),
|
||||
"data": {
|
||||
"conversationId": conversation.id,
|
||||
"assistantId": conversation.assistant_id,
|
||||
"assistantName": conversation.assistant_name,
|
||||
"analysis": result,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def enqueue_analysis_completed(
|
||||
session: AsyncSession,
|
||||
conversation: ConversationSession,
|
||||
result: dict[str, Any],
|
||||
) -> WebhookDelivery | None:
|
||||
setting = await session.get(SiteSetting, ANALYSIS_WEBHOOK_KEY)
|
||||
values = dict(setting.value or {}) if setting else {}
|
||||
secrets = dict(setting.secrets or {}) if setting else {}
|
||||
url = str(values.get("url") or "")
|
||||
secret = str(secrets.get("secret") or "")
|
||||
if not values.get("enabled") or not url or not secret:
|
||||
return None
|
||||
|
||||
event_id = f"evt_{uuid.uuid4().hex}"
|
||||
now = datetime.now(UTC)
|
||||
delivery = WebhookDelivery(
|
||||
id=f"dlv_{uuid.uuid4().hex}",
|
||||
event_id=event_id,
|
||||
event_type=ANALYSIS_COMPLETED_EVENT,
|
||||
conversation_id=conversation.id,
|
||||
payload=analysis_completed_payload(
|
||||
conversation,
|
||||
result,
|
||||
event_id=event_id,
|
||||
timestamp=now,
|
||||
),
|
||||
url=url,
|
||||
secrets={"secret": secret},
|
||||
status="pending",
|
||||
next_attempt_at=now,
|
||||
)
|
||||
session.add(delivery)
|
||||
return delivery
|
||||
61
backend/services/webhooks/security.py
Normal file
61
backend/services/webhooks/security.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Validate webhook destinations before every outbound request."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
class UnsafeWebhookUrl(ValueError):
|
||||
"""Raised when a URL could reach local or otherwise non-public infrastructure."""
|
||||
|
||||
def __init__(self, message: str, *, retryable: bool = False):
|
||||
super().__init__(message)
|
||||
self.retryable = retryable
|
||||
|
||||
|
||||
def _public_ip(address: str) -> bool:
|
||||
try:
|
||||
return ipaddress.ip_address(address).is_global
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
async def validate_webhook_url(url: str) -> str:
|
||||
"""Require HTTPS and ensure every currently resolved address is public."""
|
||||
value = url.strip()
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
raise UnsafeWebhookUrl("Webhook URL 必须是完整的 HTTPS 地址")
|
||||
if parsed.username or parsed.password:
|
||||
raise UnsafeWebhookUrl("Webhook URL 不能包含用户名或密码")
|
||||
|
||||
try:
|
||||
port = parsed.port or 443
|
||||
except ValueError as exc:
|
||||
raise UnsafeWebhookUrl("Webhook URL 端口无效") from exc
|
||||
|
||||
try:
|
||||
direct_ip = ipaddress.ip_address(parsed.hostname)
|
||||
except ValueError:
|
||||
direct_ip = None
|
||||
if direct_ip is not None:
|
||||
if not direct_ip.is_global:
|
||||
raise UnsafeWebhookUrl("Webhook URL 不能指向内网或本机地址")
|
||||
return value
|
||||
|
||||
try:
|
||||
records = await asyncio.to_thread(
|
||||
socket.getaddrinfo,
|
||||
parsed.hostname,
|
||||
port,
|
||||
type=socket.SOCK_STREAM,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise UnsafeWebhookUrl("Webhook 域名暂时无法解析", retryable=True) from exc
|
||||
addresses = {str(record[4][0]) for record in records}
|
||||
if not addresses or any(not _public_ip(address) for address in addresses):
|
||||
raise UnsafeWebhookUrl("Webhook 域名解析到了内网或非公网地址")
|
||||
return value
|
||||
120
backend/services/webhooks/worker.py
Normal file
120
backend/services/webhooks/worker.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""PostgreSQL-backed webhook delivery worker with bounded retries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from db.models import WebhookDelivery
|
||||
from db.session import SessionLocal
|
||||
from loguru import logger
|
||||
from services.webhooks.delivery import DeliveryResult, deliver_webhook, should_retry
|
||||
from services.webhooks.security import UnsafeWebhookUrl
|
||||
from sqlalchemy import or_, select, update
|
||||
|
||||
|
||||
POLL_INTERVAL_SECONDS = 2.0
|
||||
MAX_ATTEMPTS = 5
|
||||
RETRY_DELAYS_SECONDS = (30, 120, 600, 3600)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimedDelivery:
|
||||
id: str
|
||||
event_id: str
|
||||
url: str
|
||||
secret: str
|
||||
payload: dict
|
||||
|
||||
|
||||
async def recover_interrupted_webhooks() -> None:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(
|
||||
update(WebhookDelivery)
|
||||
.where(WebhookDelivery.status == "processing")
|
||||
.values(status="pending", next_attempt_at=datetime.now(UTC))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def claim_pending_webhook() -> ClaimedDelivery | None:
|
||||
now = datetime.now(UTC)
|
||||
async with SessionLocal() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(WebhookDelivery)
|
||||
.where(
|
||||
WebhookDelivery.status == "pending",
|
||||
or_(
|
||||
WebhookDelivery.next_attempt_at.is_(None),
|
||||
WebhookDelivery.next_attempt_at <= now,
|
||||
),
|
||||
)
|
||||
.order_by(WebhookDelivery.next_attempt_at, WebhookDelivery.created_at)
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not row:
|
||||
return None
|
||||
row.status = "processing"
|
||||
await session.commit()
|
||||
return ClaimedDelivery(
|
||||
id=row.id,
|
||||
event_id=row.event_id,
|
||||
url=row.url,
|
||||
secret=str((row.secrets or {}).get("secret") or ""),
|
||||
payload=dict(row.payload or {}),
|
||||
)
|
||||
|
||||
|
||||
async def finish_delivery(delivery_id: str, result: DeliveryResult) -> None:
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(WebhookDelivery, delivery_id)
|
||||
if not row or row.status != "processing":
|
||||
return
|
||||
row.attempt_count += 1
|
||||
row.last_status_code = result.status_code
|
||||
row.last_error = result.error[:2048]
|
||||
if result.ok:
|
||||
row.status = "delivered"
|
||||
row.delivered_at = datetime.now(UTC)
|
||||
row.next_attempt_at = None
|
||||
elif should_retry(result.status_code) and row.attempt_count < MAX_ATTEMPTS:
|
||||
delay_index = min(row.attempt_count - 1, len(RETRY_DELAYS_SECONDS) - 1)
|
||||
row.status = "pending"
|
||||
row.next_attempt_at = datetime.now(UTC) + timedelta(
|
||||
seconds=RETRY_DELAYS_SECONDS[delay_index]
|
||||
)
|
||||
else:
|
||||
row.status = "failed"
|
||||
row.next_attempt_at = None
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def run_webhook_worker() -> None:
|
||||
logger.info("通话分析 Webhook worker 已启动")
|
||||
while True:
|
||||
try:
|
||||
delivery = await claim_pending_webhook()
|
||||
if delivery:
|
||||
try:
|
||||
result = await deliver_webhook(
|
||||
url=delivery.url,
|
||||
secret=delivery.secret,
|
||||
event_id=delivery.event_id,
|
||||
payload=delivery.payload,
|
||||
)
|
||||
except UnsafeWebhookUrl as exc:
|
||||
# DNS outages are transient; policy violations are permanent.
|
||||
result = DeliveryResult(
|
||||
False, None if exc.retryable else 400, 0, str(exc)
|
||||
)
|
||||
await finish_delivery(delivery.id, result)
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Webhook worker 暂时不可用:{exc}")
|
||||
await asyncio.sleep(POLL_INTERVAL_SECONDS)
|
||||
82
backend/tests/test_webhooks.py
Normal file
82
backend/tests/test_webhooks.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from services.webhooks.delivery import (
|
||||
canonical_json,
|
||||
should_retry,
|
||||
webhook_signature,
|
||||
)
|
||||
from services.webhooks.events import analysis_completed_payload
|
||||
from services.webhooks.security import UnsafeWebhookUrl, validate_webhook_url
|
||||
|
||||
|
||||
class WebhookSecurityTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_requires_https(self):
|
||||
with self.assertRaises(UnsafeWebhookUrl):
|
||||
await validate_webhook_url("http://hooks.example.com/events")
|
||||
|
||||
async def test_rejects_loopback_ip(self):
|
||||
with self.assertRaises(UnsafeWebhookUrl):
|
||||
await validate_webhook_url("https://127.0.0.1/events")
|
||||
|
||||
async def test_rejects_hostname_resolving_to_private_ip(self):
|
||||
records = [(2, 1, 6, "", ("10.0.0.8", 443))]
|
||||
with patch("services.webhooks.security.socket.getaddrinfo", return_value=records):
|
||||
with self.assertRaises(UnsafeWebhookUrl):
|
||||
await validate_webhook_url("https://hooks.example.com/events")
|
||||
|
||||
async def test_accepts_hostname_resolving_to_public_ip(self):
|
||||
records = [(2, 1, 6, "", ("8.8.8.8", 443))]
|
||||
with patch("services.webhooks.security.socket.getaddrinfo", return_value=records):
|
||||
result = await validate_webhook_url("https://hooks.example.com/events")
|
||||
self.assertEqual(result, "https://hooks.example.com/events")
|
||||
|
||||
|
||||
class WebhookDeliveryTest(unittest.TestCase):
|
||||
def test_signature_uses_timestamp_dot_raw_body(self):
|
||||
body = canonical_json({"b": 2, "a": "中文"})
|
||||
expected = hmac.new(
|
||||
b"secret",
|
||||
b"1700000000." + body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
self.assertEqual(
|
||||
webhook_signature("secret", "1700000000", body),
|
||||
f"v1={expected}",
|
||||
)
|
||||
|
||||
def test_only_transient_responses_are_retried(self):
|
||||
for status in (None, 408, 429, 500, 503):
|
||||
self.assertTrue(should_retry(status))
|
||||
for status in (200, 301, 400, 401, 404):
|
||||
self.assertFalse(should_retry(status))
|
||||
|
||||
def test_analysis_event_has_stable_envelope(self):
|
||||
conversation = SimpleNamespace(
|
||||
id="conv_001",
|
||||
assistant_id="asst_001",
|
||||
assistant_name="销售助手",
|
||||
)
|
||||
from datetime import UTC, datetime
|
||||
|
||||
payload = analysis_completed_payload(
|
||||
conversation, # type: ignore[arg-type]
|
||||
{"customer_intent": "high"},
|
||||
event_id="evt_001",
|
||||
timestamp=datetime(2026, 8, 7, tzinfo=UTC),
|
||||
)
|
||||
self.assertEqual(payload["id"], "evt_001")
|
||||
self.assertEqual(payload["event"], "call.analysis.completed")
|
||||
self.assertEqual(payload["data"]["conversationId"], "conv_001")
|
||||
self.assertEqual(
|
||||
payload["data"]["analysis"], {"customer_intent": "high"}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -17,6 +17,7 @@ import { SectionCard } from "@/components/editor/section-card";
|
||||
import { ListPageLayout } from "@/components/layout/list-page-layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { siteSettingsApi } from "@/lib/api";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
|
||||
type TestStatus = "idle" | "loading" | "success" | "error";
|
||||
type SaveStatus = "loading" | "idle" | "saving" | "success" | "error";
|
||||
type ProfileSectionId = "webhook" | "language";
|
||||
|
||||
const profileSections = [
|
||||
@@ -35,22 +37,6 @@ const profileSections = [
|
||||
|
||||
const LANGUAGE_OPTIONS = [{ value: "zh-CN", label: "中文" }] as const;
|
||||
|
||||
function mockWebhookPayload() {
|
||||
return {
|
||||
id: "evt_mock_001",
|
||||
event: "call.analysis.completed",
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
conversationId: "conv_mock_001",
|
||||
assistantId: "asst_mock_001",
|
||||
analysis: {
|
||||
customer_intent: "high",
|
||||
need_follow_up: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isHttpsUrl(value: string): boolean {
|
||||
try {
|
||||
return new URL(value).protocol === "https:";
|
||||
@@ -66,10 +52,13 @@ function getAppScrollContainer(): HTMLElement | null {
|
||||
export function ProfilePage() {
|
||||
const [url, setUrl] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [secretConfigured, setSecretConfigured] = useState(false);
|
||||
const [secretVisible, setSecretVisible] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<TestStatus>("idle");
|
||||
const [testMessage, setTestMessage] = useState("");
|
||||
const [payload, setPayload] = useState<Record<string, unknown> | null>(null);
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("loading");
|
||||
const [saveMessage, setSaveMessage] = useState("");
|
||||
const [language, setLanguage] = useState<string>("zh-CN");
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<ProfileSectionId>("webhook");
|
||||
@@ -80,6 +69,28 @@ export function ProfilePage() {
|
||||
});
|
||||
const selectedAnchorRef = useRef<ProfileSectionId | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
siteSettingsApi
|
||||
.getAnalysisWebhook()
|
||||
.then((setting) => {
|
||||
if (!active) return;
|
||||
setUrl(setting.url);
|
||||
setSecretConfigured(setting.secretConfigured);
|
||||
setSaveStatus("idle");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!active) return;
|
||||
setSaveStatus("error");
|
||||
setSaveMessage(
|
||||
error instanceof Error ? error.message : "站点配置加载失败。",
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = getAppScrollContainer();
|
||||
if (!scrollContainer) return;
|
||||
@@ -168,7 +179,7 @@ export function ProfilePage() {
|
||||
async function sendTestEvent() {
|
||||
if (!isHttpsUrl(url.trim())) {
|
||||
setTestStatus("error");
|
||||
setTestMessage("请填写完整的 HTTPS Webhook URL。测试不会发起网络请求。");
|
||||
setTestMessage("请填写完整的 HTTPS Webhook URL。");
|
||||
setPayload(null);
|
||||
return;
|
||||
}
|
||||
@@ -176,10 +187,51 @@ export function ProfilePage() {
|
||||
setTestStatus("loading");
|
||||
setTestMessage("");
|
||||
setPayload(null);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 700));
|
||||
setPayload(mockWebhookPayload());
|
||||
setTestStatus("success");
|
||||
setTestMessage("已在浏览器中生成测试事件,未向目标地址发送请求。");
|
||||
try {
|
||||
const result = await siteSettingsApi.testAnalysisWebhook({
|
||||
url: url.trim(),
|
||||
...(secret.trim() ? { secret: secret.trim() } : {}),
|
||||
});
|
||||
setPayload(result.payload);
|
||||
setTestStatus(result.ok ? "success" : "error");
|
||||
setTestMessage(
|
||||
result.latencyMs === null
|
||||
? result.message
|
||||
: `${result.message} 耗时 ${result.latencyMs} ms。`,
|
||||
);
|
||||
} catch (error) {
|
||||
setTestStatus("error");
|
||||
setTestMessage(error instanceof Error ? error.message : "测试事件发送失败。");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWebhookSetting() {
|
||||
const normalizedUrl = url.trim();
|
||||
if (normalizedUrl && !isHttpsUrl(normalizedUrl)) {
|
||||
setSaveStatus("error");
|
||||
setSaveMessage("请填写完整的 HTTPS Webhook URL,或清空 URL 以停用。");
|
||||
return;
|
||||
}
|
||||
setSaveStatus("saving");
|
||||
setSaveMessage("");
|
||||
try {
|
||||
const setting = await siteSettingsApi.updateAnalysisWebhook({
|
||||
enabled: Boolean(normalizedUrl),
|
||||
url: normalizedUrl,
|
||||
...(secret.trim() ? { secret: secret.trim() } : {}),
|
||||
});
|
||||
setSecret("");
|
||||
setSecretConfigured(setting.secretConfigured);
|
||||
setSaveStatus("success");
|
||||
setSaveMessage(
|
||||
setting.enabled
|
||||
? "Webhook 已启用,后续通话分析完成后会自动投递。"
|
||||
: "Webhook 已停用。已保存的签名密钥不会被清除。",
|
||||
);
|
||||
} catch (error) {
|
||||
setSaveStatus("error");
|
||||
setSaveMessage(error instanceof Error ? error.message : "站点配置保存失败。");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -207,7 +259,7 @@ export function ProfilePage() {
|
||||
<SectionCard
|
||||
icon={<Webhook size={15} />}
|
||||
title="通话分析 Webhook"
|
||||
description="站点级事件出口;后端接入后将在分析完成时执行 HTTP POST"
|
||||
description="站点级事件出口;配置 URL 后将在每次通话分析完成时执行 HTTP POST"
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
<label className="block">
|
||||
@@ -218,6 +270,8 @@ export function ProfilePage() {
|
||||
value={url}
|
||||
onChange={(event) => {
|
||||
setUrl(event.target.value);
|
||||
setSaveStatus("idle");
|
||||
setSaveMessage("");
|
||||
setTestStatus("idle");
|
||||
setTestMessage("");
|
||||
setPayload(null);
|
||||
@@ -226,7 +280,7 @@ export function ProfilePage() {
|
||||
className="border-hairline-strong bg-background"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||
后端接入后仅允许 HTTPS,并将为每次投递附带稳定的事件 ID。
|
||||
仅允许公网 HTTPS 地址;清空并保存可停用 Webhook。
|
||||
</p>
|
||||
</label>
|
||||
|
||||
@@ -238,8 +292,16 @@ export function ProfilePage() {
|
||||
<Input
|
||||
type={secretVisible ? "text" : "password"}
|
||||
value={secret}
|
||||
onChange={(event) => setSecret(event.target.value)}
|
||||
placeholder="用于生成 HMAC-SHA256 签名"
|
||||
onChange={(event) => {
|
||||
setSecret(event.target.value);
|
||||
setSaveStatus("idle");
|
||||
setSaveMessage("");
|
||||
}}
|
||||
placeholder={
|
||||
secretConfigured
|
||||
? "已配置;留空表示不修改"
|
||||
: "用于生成 HMAC-SHA256 签名"
|
||||
}
|
||||
className="border-hairline-strong bg-background pr-10"
|
||||
/>
|
||||
<button
|
||||
@@ -252,8 +314,7 @@ export function ProfilePage() {
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||
计划使用 timestamp + raw body 计算签名;密钥不会出现在事件
|
||||
payload 中。
|
||||
使用 timestamp + raw body 计算签名;保存后密钥不会返回浏览器。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
@@ -263,7 +324,9 @@ export function ProfilePage() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="gap-2 border-hairline-strong"
|
||||
disabled={testStatus === "loading"}
|
||||
disabled={
|
||||
testStatus === "loading" || saveStatus === "loading"
|
||||
}
|
||||
onClick={() => void sendTestEvent()}
|
||||
>
|
||||
{testStatus === "loading" ? (
|
||||
@@ -271,14 +334,39 @@ export function ProfilePage() {
|
||||
) : (
|
||||
<Send size={15} />
|
||||
)}
|
||||
生成测试事件
|
||||
发送测试事件
|
||||
</Button>
|
||||
<Button type="button" disabled>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saveStatus === "loading" || saveStatus === "saving"}
|
||||
onClick={() => void saveWebhookSetting()}
|
||||
>
|
||||
{saveStatus === "saving" && (
|
||||
<Loader2 size={15} className="mr-2 animate-spin" />
|
||||
)}
|
||||
保存站点配置
|
||||
</Button>
|
||||
<span className="text-xs text-muted-soft">等待后端设置 API</span>
|
||||
{saveStatus === "loading" && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-soft">
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
正在加载配置
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saveMessage && (
|
||||
<div
|
||||
role="status"
|
||||
className={`rounded-xl border px-3.5 py-3 text-xs leading-5 ${
|
||||
saveStatus === "error"
|
||||
? "border-destructive/30 bg-destructive/5 text-destructive"
|
||||
: "border-hairline bg-canvas-soft text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{saveMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testMessage && (
|
||||
<div
|
||||
role="status"
|
||||
@@ -303,7 +391,7 @@ export function ProfilePage() {
|
||||
<div className="flex items-center gap-2 border-b border-hairline px-3.5 py-2.5">
|
||||
<Braces size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
Mock payload
|
||||
已发送的事件 payload
|
||||
</span>
|
||||
</div>
|
||||
<pre className="max-h-72 overflow-auto p-3.5 font-mono text-[11px] leading-5 text-muted-foreground">
|
||||
|
||||
@@ -114,6 +114,45 @@ export const authApi = {
|
||||
me: () => request<AdminUser>("/api/auth/me"),
|
||||
};
|
||||
|
||||
// ---------- 站点级设置 ----------
|
||||
export type AnalysisWebhookSetting = {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
secretConfigured: boolean;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type AnalysisWebhookTestResult = {
|
||||
ok: boolean;
|
||||
statusCode: number | null;
|
||||
latencyMs: number | null;
|
||||
message: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const siteSettingsApi = {
|
||||
getAnalysisWebhook: () =>
|
||||
request<AnalysisWebhookSetting>("/api/settings/analysis-webhook"),
|
||||
updateAnalysisWebhook: (body: {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
secret?: string;
|
||||
clearSecret?: boolean;
|
||||
}) =>
|
||||
request<AnalysisWebhookSetting>("/api/settings/analysis-webhook", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
testAnalysisWebhook: (body: { url: string; secret?: string }) =>
|
||||
request<AnalysisWebhookTestResult>(
|
||||
"/api/settings/analysis-webhook/test",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
// ---------- 接口定义驱动的模型注册表 ----------
|
||||
export type InterfaceField = {
|
||||
key: string;
|
||||
|
||||
Reference in New Issue
Block a user