Implement webhook
This commit is contained in:
@@ -26,6 +26,7 @@ import uvicorn
|
|||||||
from db.session import sync_default_tools, sync_interface_definitions
|
from db.session import sync_default_tools, sync_interface_definitions
|
||||||
from services.knowledge import recover_interrupted_documents
|
from services.knowledge import recover_interrupted_documents
|
||||||
from services.post_call.worker import recover_interrupted_analyses, run_analysis_worker
|
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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ from routes import (
|
|||||||
mcp_servers,
|
mcp_servers,
|
||||||
model_registry,
|
model_registry,
|
||||||
node_types,
|
node_types,
|
||||||
|
site_settings,
|
||||||
tools,
|
tools,
|
||||||
voice_webrtc,
|
voice_webrtc,
|
||||||
voice_ws,
|
voice_ws,
|
||||||
@@ -51,14 +53,21 @@ async def lifespan(_app: FastAPI):
|
|||||||
await sync_default_tools()
|
await sync_default_tools()
|
||||||
await recover_interrupted_documents()
|
await recover_interrupted_documents()
|
||||||
await recover_interrupted_analyses()
|
await recover_interrupted_analyses()
|
||||||
|
await recover_interrupted_webhooks()
|
||||||
analysis_worker = asyncio.create_task(
|
analysis_worker = asyncio.create_task(
|
||||||
run_analysis_worker(), name="post-call-analysis-worker"
|
run_analysis_worker(), name="post-call-analysis-worker"
|
||||||
)
|
)
|
||||||
|
webhook_worker = asyncio.create_task(
|
||||||
|
run_webhook_worker(), name="analysis-webhook-worker"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
analysis_worker.cancel()
|
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()
|
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(mcp_servers.router)
|
||||||
app.include_router(model_registry.router)
|
app.include_router(model_registry.router)
|
||||||
app.include_router(node_types.router)
|
app.include_router(node_types.router)
|
||||||
|
app.include_router(site_settings.router)
|
||||||
app.include_router(tools.router)
|
app.include_router(tools.router)
|
||||||
app.include_router(voice_webrtc.router)
|
app.include_router(voice_webrtc.router)
|
||||||
app.include_router(voice_ws.router)
|
app.include_router(voice_ws.router)
|
||||||
|
|||||||
@@ -374,3 +374,51 @@ class ConversationArtifact(Base):
|
|||||||
DateTime(timezone=True), server_default=func.now()
|
DateTime(timezone=True), server_default=func.now()
|
||||||
)
|
)
|
||||||
extra: Mapped[dict] = mapped_column(JSONB, default=dict)
|
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
|
total: int
|
||||||
page: int
|
page: int
|
||||||
page_size: 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.models import ConversationMessage, ConversationSession, ModelResource
|
||||||
from db.session import SessionLocal
|
from db.session import SessionLocal
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from services.webhooks.events import enqueue_analysis_completed
|
||||||
|
|
||||||
|
|
||||||
ANALYSIS_TIMEOUT_SECONDS = 60.0
|
ANALYSIS_TIMEOUT_SECONDS = 60.0
|
||||||
@@ -184,6 +185,7 @@ async def analyze_conversation(conversation_id: str) -> None:
|
|||||||
conversation.analysis_data = data
|
conversation.analysis_data = data
|
||||||
conversation.analysis_status = "completed"
|
conversation.analysis_status = "completed"
|
||||||
conversation.analysis_error = ""
|
conversation.analysis_error = ""
|
||||||
|
await enqueue_analysis_completed(session, conversation, result)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
async with SessionLocal() as session:
|
async with SessionLocal() as session:
|
||||||
@@ -192,4 +194,3 @@ async def analyze_conversation(conversation_id: str) -> None:
|
|||||||
conversation.analysis_status = "failed"
|
conversation.analysis_status = "failed"
|
||||||
conversation.analysis_error = str(exc)[:2048]
|
conversation.analysis_error = str(exc)[:2048]
|
||||||
await session.commit()
|
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 { ListPageLayout } from "@/components/layout/list-page-layout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { siteSettingsApi } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
type TestStatus = "idle" | "loading" | "success" | "error";
|
type TestStatus = "idle" | "loading" | "success" | "error";
|
||||||
|
type SaveStatus = "loading" | "idle" | "saving" | "success" | "error";
|
||||||
type ProfileSectionId = "webhook" | "language";
|
type ProfileSectionId = "webhook" | "language";
|
||||||
|
|
||||||
const profileSections = [
|
const profileSections = [
|
||||||
@@ -35,22 +37,6 @@ const profileSections = [
|
|||||||
|
|
||||||
const LANGUAGE_OPTIONS = [{ value: "zh-CN", label: "中文" }] as const;
|
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 {
|
function isHttpsUrl(value: string): boolean {
|
||||||
try {
|
try {
|
||||||
return new URL(value).protocol === "https:";
|
return new URL(value).protocol === "https:";
|
||||||
@@ -66,10 +52,13 @@ function getAppScrollContainer(): HTMLElement | null {
|
|||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [secret, setSecret] = useState("");
|
const [secret, setSecret] = useState("");
|
||||||
|
const [secretConfigured, setSecretConfigured] = useState(false);
|
||||||
const [secretVisible, setSecretVisible] = useState(false);
|
const [secretVisible, setSecretVisible] = useState(false);
|
||||||
const [testStatus, setTestStatus] = useState<TestStatus>("idle");
|
const [testStatus, setTestStatus] = useState<TestStatus>("idle");
|
||||||
const [testMessage, setTestMessage] = useState("");
|
const [testMessage, setTestMessage] = useState("");
|
||||||
const [payload, setPayload] = useState<Record<string, unknown> | null>(null);
|
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 [language, setLanguage] = useState<string>("zh-CN");
|
||||||
const [activeSection, setActiveSection] =
|
const [activeSection, setActiveSection] =
|
||||||
useState<ProfileSectionId>("webhook");
|
useState<ProfileSectionId>("webhook");
|
||||||
@@ -80,6 +69,28 @@ export function ProfilePage() {
|
|||||||
});
|
});
|
||||||
const selectedAnchorRef = useRef<ProfileSectionId | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const scrollContainer = getAppScrollContainer();
|
const scrollContainer = getAppScrollContainer();
|
||||||
if (!scrollContainer) return;
|
if (!scrollContainer) return;
|
||||||
@@ -168,7 +179,7 @@ export function ProfilePage() {
|
|||||||
async function sendTestEvent() {
|
async function sendTestEvent() {
|
||||||
if (!isHttpsUrl(url.trim())) {
|
if (!isHttpsUrl(url.trim())) {
|
||||||
setTestStatus("error");
|
setTestStatus("error");
|
||||||
setTestMessage("请填写完整的 HTTPS Webhook URL。测试不会发起网络请求。");
|
setTestMessage("请填写完整的 HTTPS Webhook URL。");
|
||||||
setPayload(null);
|
setPayload(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -176,10 +187,51 @@ export function ProfilePage() {
|
|||||||
setTestStatus("loading");
|
setTestStatus("loading");
|
||||||
setTestMessage("");
|
setTestMessage("");
|
||||||
setPayload(null);
|
setPayload(null);
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, 700));
|
try {
|
||||||
setPayload(mockWebhookPayload());
|
const result = await siteSettingsApi.testAnalysisWebhook({
|
||||||
setTestStatus("success");
|
url: url.trim(),
|
||||||
setTestMessage("已在浏览器中生成测试事件,未向目标地址发送请求。");
|
...(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 (
|
return (
|
||||||
@@ -207,7 +259,7 @@ export function ProfilePage() {
|
|||||||
<SectionCard
|
<SectionCard
|
||||||
icon={<Webhook size={15} />}
|
icon={<Webhook size={15} />}
|
||||||
title="通话分析 Webhook"
|
title="通话分析 Webhook"
|
||||||
description="站点级事件出口;后端接入后将在分析完成时执行 HTTP POST"
|
description="站点级事件出口;配置 URL 后将在每次通话分析完成时执行 HTTP POST"
|
||||||
>
|
>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -218,6 +270,8 @@ export function ProfilePage() {
|
|||||||
value={url}
|
value={url}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setUrl(event.target.value);
|
setUrl(event.target.value);
|
||||||
|
setSaveStatus("idle");
|
||||||
|
setSaveMessage("");
|
||||||
setTestStatus("idle");
|
setTestStatus("idle");
|
||||||
setTestMessage("");
|
setTestMessage("");
|
||||||
setPayload(null);
|
setPayload(null);
|
||||||
@@ -226,7 +280,7 @@ export function ProfilePage() {
|
|||||||
className="border-hairline-strong bg-background"
|
className="border-hairline-strong bg-background"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||||
后端接入后仅允许 HTTPS,并将为每次投递附带稳定的事件 ID。
|
仅允许公网 HTTPS 地址;清空并保存可停用 Webhook。
|
||||||
</p>
|
</p>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -238,8 +292,16 @@ export function ProfilePage() {
|
|||||||
<Input
|
<Input
|
||||||
type={secretVisible ? "text" : "password"}
|
type={secretVisible ? "text" : "password"}
|
||||||
value={secret}
|
value={secret}
|
||||||
onChange={(event) => setSecret(event.target.value)}
|
onChange={(event) => {
|
||||||
placeholder="用于生成 HMAC-SHA256 签名"
|
setSecret(event.target.value);
|
||||||
|
setSaveStatus("idle");
|
||||||
|
setSaveMessage("");
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
secretConfigured
|
||||||
|
? "已配置;留空表示不修改"
|
||||||
|
: "用于生成 HMAC-SHA256 签名"
|
||||||
|
}
|
||||||
className="border-hairline-strong bg-background pr-10"
|
className="border-hairline-strong bg-background pr-10"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -252,8 +314,7 @@ export function ProfilePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||||
计划使用 timestamp + raw body 计算签名;密钥不会出现在事件
|
使用 timestamp + raw body 计算签名;保存后密钥不会返回浏览器。
|
||||||
payload 中。
|
|
||||||
</p>
|
</p>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +324,9 @@ export function ProfilePage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-2 border-hairline-strong"
|
className="gap-2 border-hairline-strong"
|
||||||
disabled={testStatus === "loading"}
|
disabled={
|
||||||
|
testStatus === "loading" || saveStatus === "loading"
|
||||||
|
}
|
||||||
onClick={() => void sendTestEvent()}
|
onClick={() => void sendTestEvent()}
|
||||||
>
|
>
|
||||||
{testStatus === "loading" ? (
|
{testStatus === "loading" ? (
|
||||||
@@ -271,14 +334,39 @@ export function ProfilePage() {
|
|||||||
) : (
|
) : (
|
||||||
<Send size={15} />
|
<Send size={15} />
|
||||||
)}
|
)}
|
||||||
生成测试事件
|
发送测试事件
|
||||||
</Button>
|
</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>
|
</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>
|
</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 && (
|
{testMessage && (
|
||||||
<div
|
<div
|
||||||
role="status"
|
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">
|
<div className="flex items-center gap-2 border-b border-hairline px-3.5 py-2.5">
|
||||||
<Braces size={14} className="text-muted-foreground" />
|
<Braces size={14} className="text-muted-foreground" />
|
||||||
<span className="text-xs font-medium text-foreground">
|
<span className="text-xs font-medium text-foreground">
|
||||||
Mock payload
|
已发送的事件 payload
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<pre className="max-h-72 overflow-auto p-3.5 font-mono text-[11px] leading-5 text-muted-foreground">
|
<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"),
|
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 = {
|
export type InterfaceField = {
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user