ForcePilot/backend/package/yuxi/channel/worker/outbox_retry.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

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")