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相关端点,重构路由路径与校验逻辑
231 lines
9.8 KiB
Python
231 lines
9.8 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 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)}
|