ForcePilot/backend/server/routers/channels/webhook_router.py
Kris e5e9f45411 refactor(channel-routers): 批量优化各渠道路由代码与契约对齐
1. config_router: 为expected_version添加ge=1校验
2. directory_router: 补充scope校验逻辑与注释
3. login_router: 拆分强制下线权限,添加参数校验与注释更新
4. reports_router: 统一时间参数处理,修复分页限制使用契约常量
5. dashboard_router: 更新文档与响应格式,修正参数传递逻辑
6. health_router: 缩减健康检查响应字段,修复响应结构与参数校验
7. plugin_router: 新增插件目录端点,补充枚举校验与注释
8. pairing_router: 新增时间过滤参数,补充参数校验
9. __init__.py: 修复异常映射,更新trace_id获取逻辑与工具类
10. doctor_router: 重构单项检查端点,修正注释与校验逻辑
11. account_router: 新增恢复降级账户端点,补充批量操作校验
12. webhook_router: 优化webhook处理逻辑,修复流式读取与响应逻辑
13. content_review_router: 补充批量审核端点,完善参数校验与注释
14. analytics_router: 修正管道阶段描述,统一参数传递
15. wizard_router: 新增OAuth相关端点,重构路由路径与校验逻辑
2026-07-04 00:16:00 +08:00

231 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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`` 注入 ContextVarRouter 读取后透传至
``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)}