ForcePilot/backend/server/routers/channels/webhook_router.py
Kris ddafd95ff0 refactor(routers): 整理并新增多组渠道相关路由功能
1.  移除多个导出接口的显式response_model声明
2.  调整access_rule和test_case的创建接口位置,修复静态路径冲突
3.  优化适配器配置校验的异常处理逻辑
4.  重构集成路由的查询逻辑,统一使用get_integration_or_raise
5.  新增channels路由组下的capability、reports、dashboard、webhook、wizard、doctor、directory、session共8个子路由模块
6.  注册channels_router到全局路由列表
2026-07-02 03:29:06 +08:00

209 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Webhook 入站路由WHK-RECV-01 / WHK-09 / WHK-TEST
模板 B数据面用于入站接收与签名校验WHK-RECV-01 / WHK-09不依赖
管理员鉴权(签名校验下沉入站管道)。模板 A控制面端口路由用于 Webhook
测试WHK-TEST依赖 ``get_admin_user`` 鉴权,经 ``AccountManagementPort.testWebhook``
控制面管道执行。子 router 不自行设置 prefix根前缀 ``/channels`` 由
``channels_router`` 聚合 router 统一追加,最终路径为
``/channels/{channel_type}/webhook`` 等。
端点清单对应《07-投递基础设施域设计方案》§2.1
- POST /{channel_type}/webhook WHK-RECV-01 receiveWebhook
- POST /{channel_type}/webhook/signature/verify WHK-09 verifySignature
- POST /{channel_type}/webhook/test WHK-TEST testWebhook
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, ConfigDict, Field
from yuxi.channels.contract.dtos.channel import ChannelType, WebhookTestCmd
from yuxi.channels.contract.dtos.inbound import ReceiveInboundCmd
from yuxi.channels.contract.errors import NotImplementedError as ChannelNotImplementedError
from yuxi.channels.contract.errors import NotFoundError, ValidationError
from yuxi.storage.postgres.models_business import User
from server.routers.channels import (
build_operator,
dataclass_to_dict,
get_channel_use_cases,
raiseOnControlFailure,
sanitize_headers,
serialize_control_data,
)
from server.utils.auth_middleware import get_admin_user
webhook_router = APIRouter(tags=["channels-webhook"])
# Webhook 原始 body 大小上限1 MiB防止恶意大 payload 导致 OOM
MAX_WEBHOOK_BODY_SIZE = 1 * 1024 * 1024
# ---------------- Request Schemas ----------------
class SignatureVerifyRequest(BaseModel):
"""Webhook 签名验证测试请求体WHK-09"""
model_config = ConfigDict(frozen=True)
raw_event: str = Field(..., min_length=1, description="待验证的原始事件体UTF-8 字符串)")
headers: dict[str, str] = Field(..., description="渠道侧请求头(含签名字段)")
class SignatureVerifyResponse(BaseModel):
"""Webhook 签名验证测试响应体WHK-09"""
model_config = ConfigDict(frozen=True)
valid: bool = Field(..., description="签名校验是否通过")
reason: str | None = Field(default=None, description="校验失败原因(通过时为 None")
class WebhookTestRequest(BaseModel):
"""Webhook 测试请求体WHK-TEST"""
model_config = ConfigDict(frozen=True)
account_id: str | None = Field(
default=None,
description="渠道账户 ID可选缺省取该渠道首个账户",
)
event_type: str = Field(
default="test_event",
description="测试事件类型",
)
payload: dict[str, Any] | None = Field(
default=None,
description="测试事件负载(可选)",
)
# ---------------- Endpoints ----------------
@webhook_router.post("/{channel_type}/webhook", response_model=dict)
async def receive_webhook(
channel_type: ChannelType,
request: Request,
use_cases=Depends(get_channel_use_cases),
) -> dict[str, Any]:
"""接收渠道入站 webhook 回调WHK-RECV-01
采用模板 B数据面不经过控制面管道直接调用
use_cases.inbound_message.receiveWebhook(cmd)。
签名校验与幂等去重下沉到入站管道signature-verify / status-route 阶段),
Router 仅负责透传原始 body 与脱敏后的 headers。不调用 raiseOnControlFailure
fire-and-forget 模型),由渠道契约决定回调响应内容。
``ReceiveInboundCmd`` 构造时由 ``__post_init__`` 校验字段,违规抛
``ValidationError``HTTP 400交由 ``unified_error_handler`` 统一映射,
不在 Router 内捕获原生异常INV-7 禁止原生异常穿透至核心层)。
raw_body 直接 ``await request.body()`` 获取原始字节流,不经 Pydantic 解析,
避免字节序列改变导致签名校验失败。解码使用 ``errors="strict"``:非法
UTF-8 字节会显式失败并转译为 ``ValidationError``400而非静默替换为
U+FFFD 破坏原始字节序列导致签名校验失败。
"""
raw_body = await request.body()
if len(raw_body) > MAX_WEBHOOK_BODY_SIZE:
raise ValidationError(
"body",
f"webhook body size {len(raw_body)} exceeds limit {MAX_WEBHOOK_BODY_SIZE} bytes",
)
try:
raw_event = raw_body.decode("utf-8", errors="strict")
except UnicodeDecodeError as exc:
raise ValidationError(
"body",
"webhook body is not valid UTF-8",
) from exc
sanitized_headers = sanitize_headers(request.headers)
cmd = ReceiveInboundCmd(
channel_type=channel_type,
raw_event=raw_event,
headers=sanitized_headers,
)
result = await use_cases.inbound_message.receiveWebhook(cmd)
return {"success": True, "data": dataclass_to_dict(result)}
@webhook_router.post("/{channel_type}/webhook/signature/verify", response_model=dict)
async def verify_webhook_signature(
channel_type: ChannelType,
payload: SignatureVerifyRequest,
use_cases=Depends(get_channel_use_cases),
) -> dict[str, Any]:
"""测试 webhook 签名验证WHK-09
采用模板 B数据面通过 ``InboundMessagePort.verifyWebhookSignature``
调用渠道适配器的 ``verifySignature``,不触发真实消息处理链路,辅助接入
调试。与生产路径调用同一 ``verifySignature`` 适配器方法,确保测试结果与
生产签名校验行为完全一致。
异常处理HTTP 语义对齐):渠道未注册入站适配器时用例服务抛
``NotFoundError``404设计文档要求 WHK-09 显式 501故在 Router
内翻译为契约 ``NotImplementedError`` 交由 ``unified_error_handler`` 统一
映射;适配器未实现 ``verifySignature`` 抛 Python 内置 ``NotImplementedError``
时同样翻译为契约异常,避免被 ``unhandled_exception_handler`` 兜底为 500。
契约 ``NotImplementedError``(适配器显式抛出)直接上抛,由全局处理器映射为 501。
"""
try:
valid = await use_cases.inbound_message.verifyWebhookSignature(
channel_type=channel_type,
raw_event=payload.raw_event,
headers=payload.headers,
)
except NotFoundError as exc:
# 渠道未注册入站适配器:设计要求显式 501翻译为契约 NotImplementedError
# 交由 unified_error_handler 统一映射HTTP 501并保留原始 traceback。
raise ChannelNotImplementedError(
operation="verify_signature",
message=f"inbound adapter not registered for channel: {channel_type.value}",
) from exc
except NotImplementedError as exc:
# Python 内置 NotImplementedError适配器未实现 verifySignature
# 翻译为契约 NotImplementedError避免原生异常被兜底为 500。
# 契约 NotImplementedError 不在此捕获,直接上抛由全局处理器统一映射。
raise ChannelNotImplementedError(
operation="verify_signature",
message=f"verifySignature not implemented for channel: {channel_type.value}",
) from exc
reason = None if valid else "signature verification failed"
return {
"success": True,
"data": SignatureVerifyResponse(valid=valid, reason=reason).model_dump(),
}
@webhook_router.post("/{channel_type}/webhook/test", response_model=dict)
async def test_webhook(
channel_type: ChannelType,
payload: WebhookTestRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""测试 Webhook 投递WHK-TEST
采用模板 A控制面端口路由构造 ``WebhookTestCmd`` 经
``AccountManagementPort.testWebhook`` 控制面管道执行isinstance 检查
``WebhookTestable`` 能力 → 调用 ``testWebhook`` 发送测试事件 → 返回
投递结果。未注册 ``WebhookTestable`` 时返回 501 NOT_IMPLEMENTED。
"""
operator = build_operator(current_user, request)
cmd = WebhookTestCmd(
channel_type=channel_type,
operator=operator,
account_id=payload.account_id,
event_type=payload.event_type,
payload=payload.payload,
)
result = await use_cases.account_management.testWebhook(cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}