ForcePilot/backend/server/routers/external_systems/webhook_router.py

554 lines
22 KiB
Python
Raw Normal View History

"""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
from typing import Any, Literal
from fastapi import APIRouter, Body, Depends, Header, Path, Query, Request
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
from yuxi.utils.crypto import SENSITIVE_HTTP_HEADERS
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]:
"""过滤敏感请求头,避免 ``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}
# ---------------- 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),
status: Literal["pending", "active", "paused", "expired", "disabled"] | None = Query(
None, description="订阅状态pending/active/paused/expired/disabled"
),
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),
processing_status: Literal["pending", "processing", "processed", "failed", "ignored", "duplicate"] | None = Query(
None, description="处理状态pending/processing/processed/failed/ignored/duplicate"
),
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),
start_time: datetime | None = Query(None, description="ISO8601 开始时间"),
end_time: datetime | None = Query(None, description="ISO8601 结束时间"),
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,
start=start_time,
end=end_time,
)
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(
subscription_id: int = Path(ge=1),
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(
subscription_id: int = Path(ge=1),
payload: UpdateWebhookSubscriptionRequest = Body(...),
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(
subscription_id: int = Path(ge=1),
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(
subscription_id: int = Path(ge=1),
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(
event_id: int = Path(ge=1),
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(
event_id: int = Path(ge=1),
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(
subscription_id: int = Path(ge=1),
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(
subscription_id: int = Path(ge=1),
payload: UpdateFilterRulesRequest = Body(...),
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(
subscription_id: int = Path(ge=1),
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(
subscription_id: int = Path(ge=1),
payload: UpdateTransformRulesRequest = Body(...),
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(
subscription_id: int = Path(ge=1),
payload: TestWebhookSubscriptionRequest = Body(...),
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(
subscription_id: int = Path(ge=1),
payload: VerifySignatureRequest = Body(...),
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()}