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到全局路由列表
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)}
|