ForcePilot/backend/package/yuxi/channel/channels/hooks/routes.py
Kris 9a8a27bf36 feat(channel): 新增渠道网关模块完整实现
本次提交新增了完整的多渠道消息网关系统,包括:
1. 支持飞书、钉钉、Web、Hook 四种渠道的适配器与配置
2. 领域模型层:消息、会话、绑定、出箱等核心实体
3. 应用服务层:管道、中间件、DTO 与业务逻辑
4. 基础设施层:持久化、过滤器、队列等端口实现
5. 接口层:REST API、SSE、WebSocket 通信端点
6. 前端页面与路由配置,添加渠道管理菜单
7. 新增相关依赖包与 docker-compose 部署配置
2026-05-30 21:53:09 +08:00

134 lines
4.2 KiB
Python

from __future__ import annotations
import hashlib
import hmac
import json
import logging
from uuid import uuid4
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from yuxi.channel.channels.hooks.translator import HooksTranslator
from yuxi.channel.domain.exception.abort_mapping import ABORT_CODE_TO_HTTP
logger = logging.getLogger(__name__)
router = APIRouter()
class ErrorResponse(BaseModel):
error: str
code: str
detail: dict | None = None
trace_id: str | None = None
class AcceptedResponse(BaseModel):
trace_id: str
status: str = "accepted"
message_id: str | None = None
def _verify_hook_auth(mapping, request: Request, body: bytes) -> None:
if not mapping.secret:
return
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if hmac.compare_digest(token, mapping.secret):
return
raise HTTPException(status_code=401, detail="invalid bearer token")
signature = request.headers.get("x-signature-256") or request.headers.get("x-hub-signature-256")
if signature:
if signature.startswith("sha256="):
signature = signature[7:]
expected = hmac.new(mapping.secret.encode(), body, hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, expected):
return
raise HTTPException(status_code=401, detail="invalid signature")
raise HTTPException(status_code=401, detail="authentication required")
@router.post("/channel/hooks/{match_path:path}", response_model=AcceptedResponse)
async def receive_hook(
match_path: str,
request: Request,
body: bytes = Body(..., max_length=256 * 1024),
):
from yuxi.channel.container import get_channel
container = get_channel(request)
if not container:
raise HTTPException(status_code=503, detail="channel not initialized")
adapter = container.adapters.get("hooks")
if not adapter:
raise HTTPException(status_code=503, detail="hooks adapter not available")
mapping = adapter.match_hook(match_path, request.headers.get("X-Hook-Source", "*"))
if not mapping:
raise HTTPException(status_code=404, detail=f"no hook mapping for path: {match_path}")
try:
if container.metrics:
await container.metrics.record_hooks_received(match_path)
except Exception:
logger.debug("failed to record hooks_received metric")
_verify_hook_auth(mapping, request, body)
if len(body) > mapping.max_body_bytes:
raise HTTPException(status_code=413, detail="payload too large")
try:
raw = json.loads(body)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="invalid JSON")
trace_id = raw.get("trace_id", request.headers.get("x-trace-id", str(uuid4())))
idempotency_key = request.headers.get("idempotency-key")
message = HooksTranslator.translate(raw, mapping)
message.metadata["trace_id"] = trace_id
if idempotency_key:
message.metadata["idempotency_key"] = idempotency_key
message.metadata["client_ip"] = request.client.host if request.client else "unknown"
result = await container.inbound_service.submit(message, channel_type="hooks", trace_id=trace_id)
if result.is_aborted:
abort_code = result.abort_code or "PIPELINE_ABORTED"
http_status = ABORT_CODE_TO_HTTP.get(abort_code, 400)
raise HTTPException(
status_code=http_status,
detail=ErrorResponse(
error=result.abort_reason or "pipeline aborted",
code=abort_code,
trace_id=trace_id,
).model_dump(),
)
if result.is_skipped:
return JSONResponse(
status_code=202,
content=AcceptedResponse(
trace_id=trace_id,
status="skipped",
message_id=message.message_id,
).model_dump(),
)
return JSONResponse(
status_code=202,
content=AcceptedResponse(
trace_id=trace_id,
status="accepted",
message_id=message.message_id,
).model_dump(),
)