ForcePilot/backend/package/yuxi/channel/interfaces/sse/endpoint.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

161 lines
5.8 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import time
from dataclasses import dataclass
from fastapi import HTTPException, Request
from fastapi.responses import StreamingResponse
from yuxi.channel.domain.port.metrics_port import MetricsPort
from yuxi.channel.domain.port.sse_push_port import SsePushPort
logger = logging.getLogger(__name__)
@dataclass
class _SseConnection:
session_id: str
queue: asyncio.Queue
last_active_at: float = 0.0
class SseEndpoint(SsePushPort):
STALE_THRESHOLD_SECONDS = 300
CLEANUP_INTERVAL_SECONDS = 60
def __init__(self, *, max_connections: int = 1000, metrics: MetricsPort | None = None) -> None:
self._connections: dict[str, list[_SseConnection]] = {}
self._max_connections = max_connections
self._cleanup_task: asyncio.Task | None = None
self._metrics = metrics
@property
def connection_count(self) -> int:
return sum(len(conns) for conns in self._connections.values())
async def start(self) -> None:
logger.info("SSE endpoint started, max_connections=%d", self._max_connections)
self._cleanup_task = asyncio.create_task(self._periodic_cleanup())
async def stop(self) -> None:
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
self._cleanup_task = None
for conns in self._connections.values():
for conn in conns:
try:
conn.queue.put_nowait({"type": "shutdown", "reason": "server_stopping"})
except asyncio.QueueFull:
pass
await asyncio.sleep(1.0)
self._connections.clear()
async def subscribe(self, session_id: str, request: Request) -> StreamingResponse:
if self.connection_count >= self._max_connections:
await self._cleanup_stale()
if self.connection_count >= self._max_connections:
raise HTTPException(status_code=503, detail="SSE connection limit reached")
now = time.monotonic()
queue: asyncio.Queue = asyncio.Queue(maxsize=256)
conn = _SseConnection(session_id=session_id, queue=queue, last_active_at=now)
self._connections.setdefault(session_id, []).append(conn)
await self._update_connection_count()
async def event_stream():
try:
yield f'event: connected\ndata: {{"session_id": "{session_id}"}}\n\n'
while True:
if await request.is_disconnected():
break
try:
data = await asyncio.wait_for(queue.get(), timeout=30.0)
conn.last_active_at = time.monotonic()
yield f"event: message\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
except TimeoutError:
conn.last_active_at = time.monotonic()
yield "event: ping\ndata: {}\n\n"
finally:
conns = self._connections.get(session_id)
if conns:
try:
conns.remove(conn)
except ValueError:
pass
if not conns:
self._connections.pop(session_id, None)
await self._update_connection_count()
return StreamingResponse(event_stream(), media_type="text/event-stream")
async def push_event(self, session_id: str, data: dict) -> bool:
conns = self._connections.get(session_id)
if not conns:
return False
delivered = False
for conn in conns:
try:
conn.queue.put_nowait(data)
conn.last_active_at = time.monotonic()
delivered = True
except asyncio.QueueFull:
pass
return delivered
async def broadcast_shutdown(self) -> None:
for conns in self._connections.values():
for conn in conns:
try:
conn.queue.put_nowait({"type": "shutdown", "reason": "gateway_shutting_down"})
except asyncio.QueueFull:
pass
async def _periodic_cleanup(self) -> None:
try:
while True:
await asyncio.sleep(self.CLEANUP_INTERVAL_SECONDS)
await self._cleanup_stale()
except asyncio.CancelledError:
pass
async def _update_connection_count(self) -> None:
if self._metrics:
try:
await self._metrics.set_sse_connections(self.connection_count)
except Exception:
pass
async def _cleanup_stale(self) -> None:
now = time.monotonic()
for sid in list(self._connections):
conns = self._connections[sid]
stale = [c for c in conns if now - c.last_active_at > self.STALE_THRESHOLD_SECONDS]
for c in stale:
conns.remove(c)
if not conns:
del self._connections[sid]
if self.connection_count >= self._max_connections:
all_conns: list[tuple[str, _SseConnection]] = []
for sid, conns in self._connections.items():
for c in conns:
all_conns.append((sid, c))
all_conns.sort(key=lambda x: x[1].last_active_at)
evict_count = len(all_conns) // 4
for sid, conn in all_conns[:evict_count]:
conns = self._connections.get(sid)
if conns:
try:
conns.remove(conn)
except ValueError:
pass
if not conns:
self._connections.pop(sid, None)