2026-07-02 03:29:06 +08:00
|
|
|
|
"""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
|
2026-07-04 00:16:00 +08:00
|
|
|
|
from yuxi.channels.contract.errors import InternalError, ValidationError
|
2026-07-02 03:29:06 +08:00
|
|
|
|
from yuxi.storage.postgres.models_business import User
|
2026-07-09 04:21:51 +08:00
|
|
|
|
from yuxi.utils.crypto import SENSITIVE_HTTP_HEADERS
|
2026-07-04 00:16:00 +08:00
|
|
|
|
from yuxi.utils.trace_context import get_trace_id
|
2026-07-02 03:29:06 +08:00
|
|
|
|
|
|
|
|
|
|
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",
|
2026-07-04 00:16:00 +08:00
|
|
|
|
min_length=1,
|
2026-07-02 03:29:06 +08:00
|
|
|
|
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(数据面):不经过控制面管道,直接调用
|
2026-07-04 00:16:00 +08:00
|
|
|
|
``use_cases.inbound_message.receiveWebhook(cmd)``。
|
2026-07-02 03:29:06 +08:00
|
|
|
|
|
|
|
|
|
|
签名校验与幂等去重下沉到入站管道(signature-verify / status-route 阶段),
|
|
|
|
|
|
Router 仅负责透传原始 body 与脱敏后的 headers。不调用 raiseOnControlFailure
|
|
|
|
|
|
(fire-and-forget 模型),由渠道契约决定回调响应内容。
|
|
|
|
|
|
|
|
|
|
|
|
``ReceiveInboundCmd`` 构造时由 ``__post_init__`` 校验字段,违规抛
|
|
|
|
|
|
``ValidationError``(HTTP 400),交由 ``unified_error_handler`` 统一映射,
|
|
|
|
|
|
不在 Router 内捕获原生异常(INV-7 禁止原生异常穿透至核心层)。
|
|
|
|
|
|
|
2026-07-04 00:16:00 +08:00
|
|
|
|
raw_body 通过 ``request.stream()`` 流式读取,超限时立即中断,避免恶意
|
|
|
|
|
|
大 payload 先全量读入内存再触发检查。解码使用 ``errors="strict"``:非法
|
2026-07-02 03:29:06 +08:00
|
|
|
|
UTF-8 字节会显式失败并转译为 ``ValidationError``(400),而非静默替换为
|
|
|
|
|
|
U+FFFD 破坏原始字节序列导致签名校验失败。
|
2026-07-04 00:16:00 +08:00
|
|
|
|
|
|
|
|
|
|
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 实现)。内部错误详情已由用例服务记录日志,不在响应体中泄露。
|
2026-07-02 03:29:06 +08:00
|
|
|
|
"""
|
2026-07-04 00:16:00 +08:00
|
|
|
|
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)
|
2026-07-02 03:29:06 +08:00
|
|
|
|
try:
|
2026-07-04 00:16:00 +08:00
|
|
|
|
raw_event = bytes(raw_body).decode("utf-8", errors="strict")
|
2026-07-02 03:29:06 +08:00
|
|
|
|
except UnicodeDecodeError as exc:
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
"body",
|
|
|
|
|
|
"webhook body is not valid UTF-8",
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
sanitized_headers = sanitize_headers(request.headers)
|
2026-07-09 04:21:51 +08:00
|
|
|
|
# 合并 URL query 参数到 headers(HTTP headers 优先,query 参数补充)。
|
|
|
|
|
|
# 部分渠道(如微信公众号)将签名 / 时间戳 / nonce 等放在 URL query 中
|
|
|
|
|
|
# 而非 HTTP headers,合并后供入站管道 ``verifySignature`` 校验。
|
|
|
|
|
|
# 合并时跳过敏感字段(如 token / authorization),避免 query 参数绕过
|
|
|
|
|
|
# ``sanitize_headers`` 过滤导致敏感信息泄露到日志。
|
|
|
|
|
|
for key, value in request.query_params.items():
|
|
|
|
|
|
if key.lower() in SENSITIVE_HTTP_HEADERS:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if key not in sanitized_headers:
|
|
|
|
|
|
sanitized_headers[key] = value
|
2026-07-02 03:29:06 +08:00
|
|
|
|
cmd = ReceiveInboundCmd(
|
|
|
|
|
|
channel_type=channel_type,
|
|
|
|
|
|
raw_event=raw_event,
|
|
|
|
|
|
headers=sanitized_headers,
|
2026-07-04 00:16:00 +08:00
|
|
|
|
trace_id=get_trace_id(),
|
2026-07-02 03:29:06 +08:00
|
|
|
|
)
|
|
|
|
|
|
result = await use_cases.inbound_message.receiveWebhook(cmd)
|
2026-07-04 00:16:00 +08:00
|
|
|
|
# 渠道侧契约回包(如飞书 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,
|
|
|
|
|
|
)
|
2026-07-02 03:29:06 +08:00
|
|
|
|
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`` 适配器方法,确保测试结果与
|
|
|
|
|
|
生产签名校验行为完全一致。
|
|
|
|
|
|
|
2026-07-04 00:16:00 +08:00
|
|
|
|
``payload.headers`` 经 ``sanitize_headers`` 脱敏后再传入用例服务,与
|
|
|
|
|
|
WHK-RECV-01 路径保持一致,避免敏感头(Authorization / Cookie 等)穿透
|
|
|
|
|
|
至核心层被适配器日志记录。
|
2026-07-02 03:29:06 +08:00
|
|
|
|
|
2026-07-04 00:16:00 +08:00
|
|
|
|
异常处理:渠道未注册入站适配器时用例服务直接抛 ``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),
|
|
|
|
|
|
)
|
2026-07-02 03:29:06 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
2026-07-04 00:16:00 +08:00
|
|
|
|
"data": SignatureVerifyResponse(
|
|
|
|
|
|
valid=result.valid,
|
|
|
|
|
|
reason=result.reason,
|
|
|
|
|
|
).model_dump(),
|
2026-07-02 03:29:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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 检查
|
2026-07-04 00:16:00 +08:00
|
|
|
|
``WebhookTestAdapter`` 能力 → 调用 ``testWebhook`` 发送测试事件 → 返回
|
|
|
|
|
|
投递结果。未注册 ``WebhookTestAdapter`` 时返回 501 NOT_IMPLEMENTED。
|
|
|
|
|
|
|
|
|
|
|
|
``WebhookTestCmd.__post_init__`` 校验 ``channel_type`` 与 ``event_type``
|
|
|
|
|
|
非空,Pydantic 层 ``WebhookTestRequest.event_type`` 的 ``min_length=1``
|
|
|
|
|
|
在 HTTP 边界先做第一道拦截,DTO 层再做第二道校验(INV-8 双层防御)。
|
2026-07-02 03:29:06 +08:00
|
|
|
|
"""
|
|
|
|
|
|
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)}
|