"""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)}