本次提交新增了完整的多渠道消息网关系统,包括: 1. 支持飞书、钉钉、Web、Hook 四种渠道的适配器与配置 2. 领域模型层:消息、会话、绑定、出箱等核心实体 3. 应用服务层:管道、中间件、DTO 与业务逻辑 4. 基础设施层:持久化、过滤器、队列等端口实现 5. 接口层:REST API、SSE、WebSocket 通信端点 6. 前端页面与路由配置,添加渠道管理菜单 7. 新增相关依赖包与 docker-compose 部署配置
161 lines
5.8 KiB
Python
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)
|