"""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 InternalError, ValidationError from yuxi.storage.postgres.models_business import User from yuxi.utils.trace_context import get_trace_id 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", min_length=1, 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 通过 ``request.stream()`` 流式读取,超限时立即中断,避免恶意 大 payload 先全量读入内存再触发检查。解码使用 ``errors="strict"``:非法 UTF-8 字节会显式失败并转译为 ``ValidationError``(400),而非静默替换为 U+FFFD 破坏原始字节序列导致签名校验失败。 trace_id 由 ``TraceIdMiddleware`` 注入 ContextVar,Router 读取后透传至 ``ReceiveInboundCmd.trace_id``,确保全链路追踪 ID 一致(中间件 → Router → 用例服务 → 入站管道),避免断链。 Webhook 配置验证请求(如飞书 challenge 握手)由用例服务短路返回 ``InboundResult.raw_response``,Router 直接将其作为 HTTP 响应体返回, 跳过标准 ``{"success": True, "data": ...}`` 包装,确保渠道平台能正确 解析握手回包(如 ``{"challenge": "..."}``)。 入站管道返回 ``ack_decision="nack"`` 时表示处理失败,Router 抛 ``InternalError``(HTTP 500)并透传 ``trace_id`` 触发渠道侧重试,避免 渠道侧因 HTTP 200 错误确认已失败的消息(FR-24 显式 NACK 语义在 HTTP 层通过 5xx 实现)。内部错误详情已由用例服务记录日志,不在响应体中泄露。 """ raw_body = bytearray() async for chunk in request.stream(): # 先检查再 extend,避免单 chunk 超大 payload 先全量入内存再触发限制 if len(raw_body) + len(chunk) > MAX_WEBHOOK_BODY_SIZE: raise ValidationError( "body", f"webhook body size exceeds limit {MAX_WEBHOOK_BODY_SIZE} bytes", ) raw_body.extend(chunk) try: raw_event = bytes(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, trace_id=get_trace_id(), ) result = await use_cases.inbound_message.receiveWebhook(cmd) # 渠道侧契约回包(如飞书 challenge 握手):直接返回,跳过标准包装, # 确保渠道平台能正确解析握手回包格式。 if result.raw_response is not None: return result.raw_response # nack 表示入站管道处理失败,返回 500 触发渠道侧重试,避免渠道侧因 # HTTP 200 错误确认已失败的消息(FR-24 显式 NACK 语义在 HTTP 层通过 # 5xx 实现)。透传 trace_id 保持异常链路日志关联。内部错误详情已由 # 用例服务记录日志,不在此泄露。 if result.ack_decision == "nack": raise InternalError( message="inbound processing failed", trace_id=result.trace_id or None, ) 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`` 适配器方法,确保测试结果与 生产签名校验行为完全一致。 ``payload.headers`` 经 ``sanitize_headers`` 脱敏后再传入用例服务,与 WHK-RECV-01 路径保持一致,避免敏感头(Authorization / Cookie 等)穿透 至核心层被适配器日志记录。 异常处理:渠道未注册入站适配器时用例服务直接抛 ``NotImplementedError`` (HTTP 501),由 ``unified_error_handler`` 统一映射,Router 不做翻译, 保持与 WHK-RECV-01 路径一致的错误语义(adapter-not-registered → 501)。 """ result = await use_cases.inbound_message.verifyWebhookSignature( channel_type=channel_type, raw_event=payload.raw_event, headers=sanitize_headers(payload.headers), ) return { "success": True, "data": SignatureVerifyResponse( valid=result.valid, reason=result.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 检查 ``WebhookTestAdapter`` 能力 → 调用 ``testWebhook`` 发送测试事件 → 返回 投递结果。未注册 ``WebhookTestAdapter`` 时返回 501 NOT_IMPLEMENTED。 ``WebhookTestCmd.__post_init__`` 校验 ``channel_type`` 与 ``event_type`` 非空,Pydantic 层 ``WebhookTestRequest.event_type`` 的 ``min_length=1`` 在 HTTP 边界先做第一道拦截,DTO 层再做第二道校验(INV-8 双层防御)。 """ 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)}