2026-06-20 22:16:07 +08:00
|
|
|
|
"""Webhook 子域 Router。
|
|
|
|
|
|
|
|
|
|
|
|
外部系统限界上下文的 Webhook 管理 API,覆盖订阅 CRUD / 续期 / 事件处理。
|
|
|
|
|
|
所有端点通过 ``create_use_cases_from_db`` 装配 use_cases,经
|
|
|
|
|
|
``webhook_subscription_service`` / ``webhook_event_handler`` 端口调用用例。
|
|
|
|
|
|
|
|
|
|
|
|
路径顺序约束:静态路径 ``/subscriptions`` 和 ``/events`` 必须在动态路径
|
|
|
|
|
|
``/subscriptions/{subscription_id}`` 前声明,避免被路径参数捕获。
|
|
|
|
|
|
|
|
|
|
|
|
特殊端点:``POST /events`` 为外部系统回调入口,无认证依赖,从请求头提取
|
|
|
|
|
|
``subscription_slug`` / ``signature``,请求体解析为 ``event_type`` + ``payload``。
|
|
|
|
|
|
``ExternalSystemError``(含 ``WebhookError``)由全局异常处理器
|
|
|
|
|
|
(``server.main.external_system_error_handler``)统一映射为对应 HTTP 状态码,
|
|
|
|
|
|
本端点不捕获,保证配置类错误(404 订阅不存在)与安全类错误(400 验签失败)
|
|
|
|
|
|
不被 200 响应掩盖。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime
|
2026-07-04 00:16:45 +08:00
|
|
|
|
from typing import Any, Literal
|
2026-06-20 22:16:07 +08:00
|
|
|
|
|
2026-07-04 00:16:45 +08:00
|
|
|
|
from fastapi import APIRouter, Body, Depends, Header, Path, Query, Request
|
2026-06-20 22:16:07 +08:00
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
from yuxi.external_systems.infrastructure.container import create_use_cases_from_db
|
|
|
|
|
|
from yuxi.external_systems.use_cases.dto.webhook import (
|
|
|
|
|
|
BatchReplayEventsInput,
|
|
|
|
|
|
CreateWebhookSubscriptionInput,
|
|
|
|
|
|
DeleteSubscriptionInput,
|
|
|
|
|
|
GetEventInput,
|
|
|
|
|
|
GetFilterRulesInput,
|
|
|
|
|
|
GetSubscriptionInput,
|
|
|
|
|
|
GetTransformRulesInput,
|
|
|
|
|
|
HandleEventInput,
|
|
|
|
|
|
ListExpiringSubscriptionsInput,
|
|
|
|
|
|
ListFailedEventsInput,
|
|
|
|
|
|
ListRenewalFailuresInput,
|
|
|
|
|
|
ListWebhookEventsInput,
|
|
|
|
|
|
ListWebhookSubscriptionsInput,
|
|
|
|
|
|
RenewSubscriptionInput,
|
|
|
|
|
|
ReplayEventInput,
|
|
|
|
|
|
TestSubscriptionInput,
|
|
|
|
|
|
UpdateFilterRulesInput,
|
|
|
|
|
|
UpdateTransformRulesInput,
|
|
|
|
|
|
UpdateWebhookSubscriptionInput,
|
|
|
|
|
|
VerifySignatureInput,
|
|
|
|
|
|
)
|
|
|
|
|
|
from yuxi.storage.postgres.models_business import User
|
2026-06-30 16:15:09 +08:00
|
|
|
|
from yuxi.utils.crypto import SENSITIVE_HTTP_HEADERS
|
2026-06-20 22:16:07 +08:00
|
|
|
|
|
|
|
|
|
|
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
|
|
|
|
|
|
|
|
|
|
|
|
webhook_router = APIRouter(prefix="/webhooks", tags=["external-systems-webhook"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_client_ip(request: Request) -> str | None:
|
|
|
|
|
|
"""提取客户端真实 IP,优先解析 ``x-forwarded-for``(与访问日志中间件一致)。"""
|
|
|
|
|
|
forwarded_for = request.headers.get("x-forwarded-for")
|
|
|
|
|
|
if forwarded_for:
|
|
|
|
|
|
return forwarded_for.split(",")[0].strip()
|
|
|
|
|
|
if request.client:
|
|
|
|
|
|
return request.client.host
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sanitize_headers(headers: Any) -> dict[str, Any]:
|
2026-06-30 16:15:09 +08:00
|
|
|
|
"""过滤敏感请求头,避免 ``authorization`` / ``cookie`` 等泄露到持久化层。
|
|
|
|
|
|
|
|
|
|
|
|
敏感字段集合统一引用 ``yuxi.utils.crypto.SENSITIVE_HTTP_HEADERS``(单一事实源)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return {key: value for key, value in headers.items() if key.lower() not in SENSITIVE_HTTP_HEADERS}
|
2026-06-20 22:16:07 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- Request Schemas ----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CreateWebhookSubscriptionRequest(BaseModel):
|
|
|
|
|
|
"""创建 Webhook 订阅请求体。字段对齐 ``CreateWebhookSubscriptionInput``。
|
|
|
|
|
|
|
|
|
|
|
|
字段长度约束对齐 ``ExternalWebhookSubscription`` ORM 列定义,在边界层
|
|
|
|
|
|
拦截非法输入:``slug`` 对齐 String(128) 且与 system_router 共用 slug
|
|
|
|
|
|
正则;``env_key`` 对齐 String(32);``source_event_type`` 对齐 String(128);
|
|
|
|
|
|
``target_handler_type`` 对齐 String(32);``target_handler_id`` 对齐 String(64);
|
|
|
|
|
|
``callback_path`` 对齐 String(256);``secret_algorithm`` 对齐 String(32);
|
|
|
|
|
|
``retention_days`` 对齐 Integer 且限制合理范围。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
|
|
|
|
slug: str = Field(..., min_length=1, max_length=128, pattern=r"^[a-zA-Z_][a-zA-Z0-9_-]{0,127}$")
|
|
|
|
|
|
name: str = Field(..., min_length=1, max_length=128)
|
|
|
|
|
|
system_id: int = Field(..., ge=1)
|
|
|
|
|
|
source_event_type: str = Field(..., min_length=1, max_length=128)
|
|
|
|
|
|
target_handler_type: str = Field(..., min_length=1, max_length=32)
|
|
|
|
|
|
callback_path: str = Field(..., min_length=1, max_length=256)
|
|
|
|
|
|
description: str = ""
|
|
|
|
|
|
env_key: str = Field(default="default", max_length=32)
|
|
|
|
|
|
target_handler_id: str | None = Field(default=None, max_length=64)
|
|
|
|
|
|
secret_algorithm: str = Field(default="hmac_sha256", max_length=32)
|
|
|
|
|
|
filter_rules: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
transform_rules: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
enabled: bool = True
|
|
|
|
|
|
expires_at: datetime | None = None
|
|
|
|
|
|
auto_renew: bool = True
|
|
|
|
|
|
retention_days: int = Field(default=30, ge=1, le=3650)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UpdateWebhookSubscriptionRequest(BaseModel):
|
|
|
|
|
|
"""更新 Webhook 订阅请求体。``id`` 由路径提供,``updated_by`` 由认证用户填充。
|
|
|
|
|
|
|
|
|
|
|
|
字段长度约束对齐 ``ExternalWebhookSubscription`` ORM 列定义,仅透传客户端
|
|
|
|
|
|
显式设置的字段(通过 ``exclude_unset=True``),未设置字段保持 ``None`` 以
|
|
|
|
|
|
保留部分更新语义。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
|
|
|
|
name: str | None = Field(default=None, min_length=1, max_length=128)
|
|
|
|
|
|
description: str | None = None
|
|
|
|
|
|
target_handler_id: str | None = Field(default=None, max_length=64)
|
|
|
|
|
|
filter_rules: dict[str, Any] | None = None
|
|
|
|
|
|
transform_rules: dict[str, Any] | None = None
|
|
|
|
|
|
enabled: bool | None = None
|
|
|
|
|
|
expires_at: datetime | None = None
|
|
|
|
|
|
auto_renew: bool | None = None
|
|
|
|
|
|
retention_days: int | None = Field(default=None, ge=1, le=3650)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class HandleEventRequest(BaseModel):
|
|
|
|
|
|
"""Webhook 事件投递请求体。``subscription_slug`` / ``signature`` 由请求头提供。
|
|
|
|
|
|
|
|
|
|
|
|
``event_type`` 长度对齐 ``ExternalWebhookEvent.event_type`` String(128)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
|
|
|
|
event_type: str = Field(..., min_length=1, max_length=128)
|
|
|
|
|
|
payload: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
external_event_id: str | None = Field(default=None, max_length=256)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BatchReplayEventsRequest(BaseModel):
|
|
|
|
|
|
"""批量重放 Webhook 事件请求体。"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
|
|
|
|
event_ids: list[int] = Field(..., min_length=1, max_length=100)
|
|
|
|
|
|
subscription_id: int | None = Field(default=None, ge=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestWebhookSubscriptionRequest(BaseModel):
|
|
|
|
|
|
"""测试 Webhook 订阅请求体。"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
|
|
|
|
payload: dict[str, Any] | None = None
|
|
|
|
|
|
event_type: str | None = Field(default=None, max_length=128)
|
|
|
|
|
|
signature: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class VerifySignatureRequest(BaseModel):
|
|
|
|
|
|
"""验证 Webhook 签名请求体。"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
|
|
|
|
payload: str = Field(..., min_length=1)
|
|
|
|
|
|
signature: str = Field(..., min_length=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UpdateFilterRulesRequest(BaseModel):
|
|
|
|
|
|
"""更新 Webhook 订阅过滤规则请求体。"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
|
|
|
|
filter_rules: dict[str, Any]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UpdateTransformRulesRequest(BaseModel):
|
|
|
|
|
|
"""更新 Webhook 订阅转换规则请求体。"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
|
|
|
|
transform_rules: dict[str, Any]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 静态路径端点(必须在 /subscriptions/{subscription_id} 之前声明) ----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.get("/subscriptions", response_model=dict)
|
|
|
|
|
|
async def list_subscriptions(
|
|
|
|
|
|
limit: int = Query(100, ge=1, le=500),
|
|
|
|
|
|
offset: int = Query(0, ge=0),
|
|
|
|
|
|
system_id: int | None = Query(None, ge=1),
|
|
|
|
|
|
env_key: str | None = Query(None, max_length=32),
|
|
|
|
|
|
source_event_type: str | None = Query(None, max_length=128),
|
2026-07-04 00:16:45 +08:00
|
|
|
|
status: Literal["pending", "active", "paused", "expired", "disabled"] | None = Query(
|
|
|
|
|
|
None, description="订阅状态:pending/active/paused/expired/disabled"
|
|
|
|
|
|
),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
enabled: bool | None = Query(None),
|
|
|
|
|
|
target_handler_type: str | None = Query(None, max_length=32),
|
|
|
|
|
|
target_handler_id: str | None = Query(None, max_length=64),
|
|
|
|
|
|
auto_renew: bool | None = Query(None),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""分页列出 Webhook 订阅。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = ListWebhookSubscriptionsInput(
|
|
|
|
|
|
page=offset // limit + 1,
|
|
|
|
|
|
page_size=limit,
|
|
|
|
|
|
system_id=system_id,
|
|
|
|
|
|
env_key=env_key,
|
|
|
|
|
|
source_event_type=source_event_type,
|
|
|
|
|
|
status=status,
|
|
|
|
|
|
enabled=enabled,
|
|
|
|
|
|
target_handler_type=target_handler_type,
|
|
|
|
|
|
target_handler_id=target_handler_id,
|
|
|
|
|
|
auto_renew=auto_renew,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.list_subscriptions(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.post("/subscriptions", response_model=dict)
|
|
|
|
|
|
async def create_subscription(
|
|
|
|
|
|
payload: CreateWebhookSubscriptionRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""创建 Webhook 订阅。``created_by`` 由当前管理员填充。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = CreateWebhookSubscriptionInput(
|
|
|
|
|
|
**payload.model_dump(),
|
|
|
|
|
|
created_by=current_user.uid,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.create_subscription(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.post("/events", response_model=dict)
|
|
|
|
|
|
async def handle_event(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
payload: HandleEventRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
subscription_slug: str = Header(..., alias="X-Webhook-Subscription", max_length=128),
|
|
|
|
|
|
signature: str | None = Header(None, alias="X-Webhook-Signature", max_length=512),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""处理 Webhook 事件投递。
|
|
|
|
|
|
|
|
|
|
|
|
无认证端点:外部系统回调入口。从请求头提取 ``subscription_slug`` / ``signature``,
|
|
|
|
|
|
请求体解析为 ``event_type`` + ``payload``。``ExternalSystemError``(含
|
|
|
|
|
|
``WebhookError``)由全局异常处理器统一映射为对应 HTTP 状态码,本端点不捕获,
|
|
|
|
|
|
保证配置类错误(404 订阅不存在)与安全类错误(400 验签失败)不被 200 响应掩盖。
|
|
|
|
|
|
|
|
|
|
|
|
``signature`` 可选:订阅未配置 secret 时不要求签名;订阅配置 secret 时由
|
|
|
|
|
|
use case 层 ``WebhookEventHandler.handle_event`` 强制验签。请求头在透传到
|
|
|
|
|
|
持久化前由 ``_sanitize_headers`` 剔除 ``authorization`` / ``cookie`` 等敏感字段。
|
|
|
|
|
|
"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = HandleEventInput(
|
|
|
|
|
|
subscription_slug=subscription_slug,
|
|
|
|
|
|
event_type=payload.event_type,
|
|
|
|
|
|
payload=payload.payload,
|
|
|
|
|
|
headers=_sanitize_headers(request.headers),
|
|
|
|
|
|
signature=signature,
|
|
|
|
|
|
source_ip=_extract_client_ip(request),
|
|
|
|
|
|
external_event_id=payload.external_event_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.webhook_event_handler.handle_event(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.get("/events", response_model=dict)
|
|
|
|
|
|
async def list_events(
|
|
|
|
|
|
limit: int = Query(100, ge=1, le=500),
|
|
|
|
|
|
offset: int = Query(0, ge=0),
|
|
|
|
|
|
subscription_id: int | None = Query(None, ge=1),
|
|
|
|
|
|
subscription_slug: str | None = Query(None, max_length=128),
|
|
|
|
|
|
event_type: str | None = Query(None, max_length=128),
|
2026-07-04 00:16:45 +08:00
|
|
|
|
processing_status: Literal["pending", "processing", "processed", "failed", "ignored", "duplicate"] | None = Query(
|
|
|
|
|
|
None, description="处理状态:pending/processing/processed/failed/ignored/duplicate"
|
|
|
|
|
|
),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
correlation_id: str | None = Query(None, max_length=128),
|
|
|
|
|
|
triggered_execution_id: str | None = Query(None, max_length=128),
|
|
|
|
|
|
signature_verified: bool | None = Query(None),
|
|
|
|
|
|
source_ip: str | None = Query(None, max_length=64),
|
2026-07-02 03:29:06 +08:00
|
|
|
|
start_time: datetime | None = Query(None, description="ISO8601 开始时间"),
|
|
|
|
|
|
end_time: datetime | None = Query(None, description="ISO8601 结束时间"),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""分页列出 Webhook 事件。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = ListWebhookEventsInput(
|
|
|
|
|
|
page=offset // limit + 1,
|
|
|
|
|
|
page_size=limit,
|
|
|
|
|
|
subscription_id=subscription_id,
|
|
|
|
|
|
subscription_slug=subscription_slug,
|
|
|
|
|
|
event_type=event_type,
|
|
|
|
|
|
processing_status=processing_status,
|
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
|
triggered_execution_id=triggered_execution_id,
|
|
|
|
|
|
signature_verified=signature_verified,
|
|
|
|
|
|
source_ip=source_ip,
|
2026-07-02 03:29:06 +08:00
|
|
|
|
start=start_time,
|
|
|
|
|
|
end=end_time,
|
2026-06-20 22:16:07 +08:00
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.list_events(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.post("/events/batch-replay", response_model=dict)
|
|
|
|
|
|
async def batch_replay_events(
|
|
|
|
|
|
payload: BatchReplayEventsRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""批量重放 Webhook 事件。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = BatchReplayEventsInput(
|
|
|
|
|
|
event_ids=payload.event_ids,
|
|
|
|
|
|
subscription_id=payload.subscription_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.webhook_event_handler.batch_replay_events(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.get("/events/failed", response_model=dict)
|
|
|
|
|
|
async def list_failed_events(
|
|
|
|
|
|
max_attempts: int | None = Query(None, ge=1),
|
|
|
|
|
|
limit: int = Query(100, ge=1, le=500),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""列出处理失败的 Webhook 事件。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = ListFailedEventsInput(max_attempts=max_attempts, limit=limit)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.list_failed_events(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.get("/subscriptions/expiring", response_model=dict)
|
|
|
|
|
|
async def list_expiring_subscriptions(
|
|
|
|
|
|
days: int = Query(7, ge=1, le=365),
|
|
|
|
|
|
limit: int = Query(100, ge=1, le=500),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""列出即将过期的 Webhook 订阅。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = ListExpiringSubscriptionsInput(days=days, limit=limit)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.list_expiring_subscriptions(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.get("/subscriptions/renewal-failures", response_model=dict)
|
|
|
|
|
|
async def list_renewal_failures(
|
|
|
|
|
|
min_attempts: int = Query(1, ge=1),
|
|
|
|
|
|
limit: int = Query(100, ge=1, le=500),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""列出续期失败的 Webhook 订阅。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = ListRenewalFailuresInput(min_attempts=min_attempts, limit=limit)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.list_renewal_failures(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 动态路径端点 /subscriptions/{subscription_id} ----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.get("/subscriptions/{subscription_id}", response_model=dict)
|
|
|
|
|
|
async def get_subscription(
|
2026-07-04 00:16:45 +08:00
|
|
|
|
subscription_id: int = Path(ge=1),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""获取 Webhook 订阅详情。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = GetSubscriptionInput(id=subscription_id)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.get_subscription(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.put("/subscriptions/{subscription_id}", response_model=dict)
|
|
|
|
|
|
async def update_subscription(
|
2026-07-04 00:16:45 +08:00
|
|
|
|
subscription_id: int = Path(ge=1),
|
|
|
|
|
|
payload: UpdateWebhookSubscriptionRequest = Body(...),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""更新 Webhook 订阅。``updated_by`` 由当前管理员填充。仅透传客户端显式设置的字段。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = UpdateWebhookSubscriptionInput(
|
|
|
|
|
|
id=subscription_id,
|
|
|
|
|
|
**payload.model_dump(exclude_unset=True),
|
|
|
|
|
|
updated_by=current_user.uid,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.update_subscription(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.delete("/subscriptions/{subscription_id}", response_model=dict)
|
|
|
|
|
|
async def delete_subscription(
|
2026-07-04 00:16:45 +08:00
|
|
|
|
subscription_id: int = Path(ge=1),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""删除 Webhook 订阅。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = DeleteSubscriptionInput(id=subscription_id)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.delete_subscription(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.post("/subscriptions/{subscription_id}/renew", response_model=dict)
|
|
|
|
|
|
async def renew_subscription(
|
2026-07-04 00:16:45 +08:00
|
|
|
|
subscription_id: int = Path(ge=1),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""续期 Webhook 订阅。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = RenewSubscriptionInput(id=subscription_id)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.renew_subscription(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.get("/events/{event_id}/deliveries", response_model=dict)
|
|
|
|
|
|
async def get_event_deliveries(
|
2026-07-04 00:16:45 +08:00
|
|
|
|
event_id: int = Path(ge=1),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""查询 Webhook 事件投递历史。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = GetEventInput(id=event_id)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.get_event_deliveries(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.post("/events/{event_id}/replay", response_model=dict)
|
|
|
|
|
|
async def replay_event(
|
2026-07-04 00:16:45 +08:00
|
|
|
|
event_id: int = Path(ge=1),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""重放 Webhook 事件。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = ReplayEventInput(id=event_id)
|
|
|
|
|
|
output = await use_cases.webhook_event_handler.replay_event(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.get("/subscriptions/{subscription_id}/filter-rules", response_model=dict)
|
|
|
|
|
|
async def get_filter_rules(
|
2026-07-04 00:16:45 +08:00
|
|
|
|
subscription_id: int = Path(ge=1),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""获取 Webhook 订阅过滤规则。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = GetFilterRulesInput(id=subscription_id)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.get_filter_rules(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.put("/subscriptions/{subscription_id}/filter-rules", response_model=dict)
|
|
|
|
|
|
async def update_filter_rules(
|
2026-07-04 00:16:45 +08:00
|
|
|
|
subscription_id: int = Path(ge=1),
|
|
|
|
|
|
payload: UpdateFilterRulesRequest = Body(...),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""更新 Webhook 订阅过滤规则。``updated_by`` 由当前管理员填充。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = UpdateFilterRulesInput(
|
|
|
|
|
|
id=subscription_id,
|
|
|
|
|
|
filter_rules=payload.filter_rules,
|
|
|
|
|
|
updated_by=current_user.uid,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.update_filter_rules(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.get("/subscriptions/{subscription_id}/transform-rules", response_model=dict)
|
|
|
|
|
|
async def get_transform_rules(
|
2026-07-04 00:16:45 +08:00
|
|
|
|
subscription_id: int = Path(ge=1),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""获取 Webhook 订阅转换规则。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = GetTransformRulesInput(id=subscription_id)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.get_transform_rules(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.put("/subscriptions/{subscription_id}/transform-rules", response_model=dict)
|
|
|
|
|
|
async def update_transform_rules(
|
2026-07-04 00:16:45 +08:00
|
|
|
|
subscription_id: int = Path(ge=1),
|
|
|
|
|
|
payload: UpdateTransformRulesRequest = Body(...),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""更新 Webhook 订阅转换规则。``updated_by`` 由当前管理员填充。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = UpdateTransformRulesInput(
|
|
|
|
|
|
id=subscription_id,
|
|
|
|
|
|
transform_rules=payload.transform_rules,
|
|
|
|
|
|
updated_by=current_user.uid,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.update_transform_rules(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.post("/subscriptions/{subscription_id}/test", response_model=dict)
|
|
|
|
|
|
async def test_subscription(
|
2026-07-04 00:16:45 +08:00
|
|
|
|
subscription_id: int = Path(ge=1),
|
|
|
|
|
|
payload: TestWebhookSubscriptionRequest = Body(...),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""测试 Webhook 订阅。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = TestSubscriptionInput(
|
|
|
|
|
|
id=subscription_id,
|
|
|
|
|
|
payload=payload.payload,
|
|
|
|
|
|
event_type=payload.event_type,
|
|
|
|
|
|
signature=payload.signature,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.test_subscription(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@webhook_router.post("/subscriptions/{subscription_id}/verify-signature", response_model=dict)
|
|
|
|
|
|
async def verify_signature(
|
2026-07-04 00:16:45 +08:00
|
|
|
|
subscription_id: int = Path(ge=1),
|
|
|
|
|
|
payload: VerifySignatureRequest = Body(...),
|
2026-06-20 22:16:07 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""验证 Webhook 签名。"""
|
|
|
|
|
|
use_cases = create_use_cases_from_db(db)
|
|
|
|
|
|
input_dto = VerifySignatureInput(
|
|
|
|
|
|
id=subscription_id,
|
|
|
|
|
|
payload=payload.payload,
|
|
|
|
|
|
signature=payload.signature,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await use_cases.webhook_subscription_service.verify_signature(input_dto)
|
|
|
|
|
|
return {"success": True, "data": output.model_dump()}
|