Implement webhook
This commit is contained in:
+11
-1
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Global webhook delivery helpers."""
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user