127 lines
4.1 KiB
Python
127 lines
4.1 KiB
Python
"""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,
|
|
)
|