209 lines
8.7 KiB
Python
209 lines
8.7 KiB
Python
|
|
"""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)}
|