Files
SmartUp/backend/app/services/webhook_service.py
T
2026-05-29 17:51:12 +08:00

288 lines
9.1 KiB
Python

"""Send webhook notifications and write notification logs."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
import httpx
from sqlalchemy.orm import Session
from app.models.webhook_config import WebhookConfig
from app.models.notification_log import NotificationLog
from app.utils.dingtalk import (
dingtalk_signed_url,
format_dingtalk_rate_changed,
format_dingtalk_website_rate_changed,
format_dingtalk_status,
format_dingtalk_balance_low,
format_dingtalk_priority_changed,
)
def _now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def _log(
db: Session,
webhook: WebhookConfig,
event_type: str,
payload: dict[str, Any],
status: str,
response_text: str,
) -> None:
entry = NotificationLog(
webhook_config_id=webhook.id,
webhook_name=webhook.name,
event_type=event_type,
payload_json=json.dumps(payload, ensure_ascii=False),
status=status,
response_text=response_text[:2000] if response_text else None,
)
db.add(entry)
db.commit()
def _send_generic(url: str, payload: dict[str, Any], timeout: float = 15.0) -> str:
resp = httpx.post(
url,
json=payload,
headers={"Content-Type": "application/json", "User-Agent": "SmartUp/1.0"},
timeout=timeout,
)
resp.raise_for_status()
return resp.text[:500]
def _send_dingtalk(url: str, secret: str, payload: dict[str, Any], timeout: float = 15.0) -> str:
signed = dingtalk_signed_url(url, secret) if secret else url
resp = httpx.post(
signed,
json=payload,
headers={"Content-Type": "application/json", "User-Agent": "SmartUp/1.0"},
timeout=timeout,
)
resp.raise_for_status()
result = resp.json()
if result.get("errcode", 0) != 0:
raise RuntimeError(f"DingTalk error: {resp.text[:300]}")
return resp.text[:500]
def send_rate_changed(
db: Session,
upstream_id: int,
upstream_name: str,
base_url: str,
changes: list[dict[str, Any]],
) -> None:
webhooks = (
db.query(WebhookConfig)
.filter(WebhookConfig.enabled == True)
.all()
)
changed_at = _now_iso()
generic_payload = {
"event": "upstream_rate_changed",
"upstream": {"id": upstream_id, "name": upstream_name, "base_url": base_url},
"changed_at": changed_at,
"changes": changes,
}
for wh in webhooks:
events = json.loads(wh.events_json or "[]")
if "upstream_rate_changed" not in events:
continue
try:
if wh.type == "dingtalk":
msg = format_dingtalk_rate_changed(upstream_name, changed_at, changes)
resp_text = _send_dingtalk(wh.url, wh.secret, msg)
else:
resp_text = _send_generic(wh.url, generic_payload)
_log(db, wh, "upstream_rate_changed", generic_payload, "success", resp_text)
except Exception as exc:
_log(db, wh, "upstream_rate_changed", generic_payload, "failed", str(exc))
def send_website_rate_changed(
db: Session,
website_id: int,
website_name: str,
base_url: str,
binding_id: int,
target_group_id: str,
target_group_name: str,
old_rate: Any,
new_rate: Any,
source_rates: list[dict[str, Any]],
) -> None:
webhooks = (
db.query(WebhookConfig)
.filter(WebhookConfig.enabled == True)
.all()
)
changed_at = _now_iso()
generic_payload = {
"event": "website_rate_changed",
"website": {"id": website_id, "name": website_name, "base_url": base_url},
"binding": {"id": binding_id},
"target_group": {
"id": target_group_id,
"name": target_group_name,
"old_rate": old_rate,
"new_rate": new_rate,
},
"source_rates": source_rates,
"changed_at": changed_at,
}
for wh in webhooks:
events = json.loads(wh.events_json or "[]")
if "website_rate_changed" not in events:
continue
try:
if wh.type == "dingtalk":
msg = format_dingtalk_website_rate_changed(
website_name, target_group_name, changed_at, old_rate, new_rate
)
resp_text = _send_dingtalk(wh.url, wh.secret, msg)
else:
resp_text = _send_generic(wh.url, generic_payload)
_log(db, wh, "website_rate_changed", generic_payload, "success", resp_text)
except Exception as exc:
_log(db, wh, "website_rate_changed", generic_payload, "failed", str(exc))
def send_status_event(
db: Session,
upstream_id: int,
upstream_name: str,
base_url: str,
event: str,
error: str = "",
) -> None:
webhooks = (
db.query(WebhookConfig)
.filter(WebhookConfig.enabled == True)
.all()
)
changed_at = _now_iso()
generic_payload = {
"event": event,
"upstream": {"id": upstream_id, "name": upstream_name, "base_url": base_url},
"changed_at": changed_at,
"error": error,
}
for wh in webhooks:
events = json.loads(wh.events_json or "[]")
if event not in events:
continue
try:
if wh.type == "dingtalk":
msg = format_dingtalk_status(upstream_name, event, changed_at, error)
resp_text = _send_dingtalk(wh.url, wh.secret, msg)
else:
resp_text = _send_generic(wh.url, generic_payload)
_log(db, wh, event, generic_payload, "success", resp_text)
except Exception as exc:
_log(db, wh, event, generic_payload, "failed", str(exc))
def send_balance_low(
db: Session,
upstream_id: int,
upstream_name: str,
base_url: str,
balance: float,
threshold: float,
) -> None:
webhooks = (
db.query(WebhookConfig)
.filter(WebhookConfig.enabled == True)
.all()
)
event = "upstream_balance_low"
changed_at = _now_iso()
generic_payload = {
"event": event,
"upstream": {"id": upstream_id, "name": upstream_name, "base_url": base_url},
"balance": balance,
"threshold": threshold,
"changed_at": changed_at,
}
for wh in webhooks:
events = json.loads(wh.events_json or "[]")
if event not in events:
continue
try:
if wh.type == "dingtalk":
msg = format_dingtalk_balance_low(upstream_name, balance, threshold, changed_at)
resp_text = _send_dingtalk(wh.url, wh.secret, msg)
else:
resp_text = _send_generic(wh.url, generic_payload)
_log(db, wh, event, generic_payload, "success", resp_text)
except Exception as exc:
_log(db, wh, event, generic_payload, "failed", str(exc))
def send_account_priority_changed(
db: Session,
website_id: int,
website_name: str,
upstream_id: int,
upstream_name: str,
updates: list[dict],
) -> None:
webhooks = (
db.query(WebhookConfig)
.filter(WebhookConfig.enabled == True)
.all()
)
event = "account_priority_changed"
changed_at = _now_iso()
success = sum(1 for u in updates if u.get("status") == "success")
failed = sum(1 for u in updates if u.get("status") == "failed")
skipped = sum(1 for u in updates if u.get("status") == "skipped")
generic_payload = {
"event": event,
"website": {"id": website_id, "name": website_name},
"upstream": {"id": upstream_id, "name": upstream_name},
"changed_at": changed_at,
"updates": updates,
"summary": {"total": len(updates), "success": success, "failed": failed, "skipped": skipped},
}
for wh in webhooks:
events = json.loads(wh.events_json or "[]")
if event not in events:
continue
try:
if wh.type == "dingtalk":
msg = format_dingtalk_priority_changed(website_name, upstream_name, changed_at, updates)
resp_text = _send_dingtalk(wh.url, wh.secret, msg)
else:
resp_text = _send_generic(wh.url, generic_payload)
_log(db, wh, event, generic_payload, "success", resp_text)
except Exception as exc:
_log(db, wh, event, generic_payload, "failed", str(exc))
def send_test_notification(db: Session, webhook: WebhookConfig) -> tuple[bool, str]:
payload = {
"event": "test",
"message": "SmartUp webhook test notification",
"sent_at": _now_iso(),
}
try:
if webhook.type == "dingtalk":
msg = {
"msgtype": "text",
"text": {"content": "✅ SmartUp webhook 测试通知\n配置正常,连接成功。"},
}
resp_text = _send_dingtalk(webhook.url, webhook.secret, msg)
else:
resp_text = _send_generic(webhook.url, payload)
_log(db, webhook, "test", payload, "success", resp_text)
return True, "发送成功"
except Exception as exc:
_log(db, webhook, "test", payload, "failed", str(exc))
return False, str(exc)