本次提交新增了完整的多渠道消息网关系统,包括: 1. 支持飞书、钉钉、Web、Hook 四种渠道的适配器与配置 2. 领域模型层:消息、会话、绑定、出箱等核心实体 3. 应用服务层:管道、中间件、DTO 与业务逻辑 4. 基础设施层:持久化、过滤器、队列等端口实现 5. 接口层:REST API、SSE、WebSocket 通信端点 6. 前端页面与路由配置,添加渠道管理菜单 7. 新增相关依赖包与 docker-compose 部署配置
125 lines
4.4 KiB
Python
125 lines
4.4 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
import redis.asyncio as aioredis
|
|
|
|
from yuxi.channel.domain.model.message.dispatch_result import SendResult
|
|
from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort
|
|
from yuxi.channel.domain.port.metrics_port import MetricsPort
|
|
from yuxi.channel.domain.repository.outbox_repository import OutboxRepositoryPort
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_OUTBOX_NOTIFY_CHANNEL = "channel:outbox:notify"
|
|
|
|
|
|
class OutboxRetryWorker:
|
|
def __init__(
|
|
self,
|
|
outbox_repo: OutboxRepositoryPort,
|
|
adapters: dict[str, ChannelAdapterPort],
|
|
redis: aioredis.Redis | None = None,
|
|
*,
|
|
metrics: MetricsPort | None = None,
|
|
poll_interval: float = 5.0,
|
|
) -> None:
|
|
self._outbox = outbox_repo
|
|
self._adapters = adapters
|
|
self._redis = redis
|
|
self._metrics = metrics
|
|
self._poll_interval = poll_interval
|
|
self._running = False
|
|
self._task: asyncio.Task | None = None
|
|
self._notify_event: asyncio.Event = asyncio.Event()
|
|
|
|
async def start(self) -> None:
|
|
self._running = True
|
|
self._task = asyncio.create_task(self._loop())
|
|
logger.info("outbox retry worker started")
|
|
|
|
async def stop(self) -> None:
|
|
self._running = False
|
|
self._notify_event.set()
|
|
if self._task:
|
|
self._task.cancel()
|
|
await asyncio.gather(self._task, return_exceptions=True)
|
|
self._task = None
|
|
logger.info("outbox retry worker stopped")
|
|
|
|
async def _loop(self) -> None:
|
|
pubsub_task: asyncio.Task | None = None
|
|
if self._redis:
|
|
pubsub_task = asyncio.create_task(self._listen_notifications())
|
|
|
|
try:
|
|
while self._running:
|
|
try:
|
|
await self._process_pending()
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as exc:
|
|
logger.error("outbox retry error: %s", exc)
|
|
|
|
try:
|
|
await asyncio.wait_for(self._notify_event.wait(), timeout=self._poll_interval)
|
|
self._notify_event.clear()
|
|
except TimeoutError:
|
|
pass
|
|
finally:
|
|
if pubsub_task:
|
|
pubsub_task.cancel()
|
|
try:
|
|
await asyncio.gather(pubsub_task, return_exceptions=True)
|
|
except Exception:
|
|
pass
|
|
|
|
async def _listen_notifications(self) -> None:
|
|
if not self._redis:
|
|
return
|
|
pubsub = self._redis.pubsub()
|
|
try:
|
|
await pubsub.subscribe(_OUTBOX_NOTIFY_CHANNEL)
|
|
async for message in pubsub.listen():
|
|
if message["type"] == "message":
|
|
self._notify_event.set()
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception:
|
|
logger.warning("outbox pubsub listener failed, falling back to polling")
|
|
finally:
|
|
try:
|
|
await pubsub.unsubscribe(_OUTBOX_NOTIFY_CHANNEL)
|
|
await pubsub.aclose()
|
|
except Exception:
|
|
pass
|
|
|
|
async def _process_pending(self) -> None:
|
|
entries = await self._outbox.fetch_pending()
|
|
for entry in entries:
|
|
adapter = self._adapters.get(entry.channel_type)
|
|
if not adapter:
|
|
continue
|
|
|
|
result: SendResult = await adapter.send_message(
|
|
entry.session_id,
|
|
entry.content,
|
|
channel_type=entry.channel_type,
|
|
metadata={"trace_id": entry.trace_id or ""},
|
|
)
|
|
|
|
if result.success:
|
|
await self._outbox.mark_sent(entry.id)
|
|
if self._metrics:
|
|
await self._metrics.record_outbox_retry_total(entry.channel_type, "success")
|
|
else:
|
|
if entry.retry_count + 1 >= entry.max_retries:
|
|
await self._outbox.mark_dead(entry.id, last_error=result.error or "max_retries_exceeded")
|
|
if self._metrics:
|
|
await self._metrics.record_outbox_retry_total(entry.channel_type, "dead")
|
|
else:
|
|
await self._outbox.mark_retrying(entry.id, last_error=result.error)
|
|
if self._metrics:
|
|
await self._metrics.record_outbox_retry_total(entry.channel_type, "retrying")
|