Files
awoooi/apps/api/src/services/signal_producer.py
OG T a470a514e6 refactor(api): Phase 17 P0 Router 層違規全部修復
消除 Router 層直接存取 Redis/DB 的違規:

incidents.py (6 處):
- 改用 IncidentService.get_active_incidents()
- 改用 IncidentService.get_from_working_memory()
- 改用 IncidentService.update_outcome()
- 改用 IncidentService.resolve_incident()
- 改用 IncidentService.find_by_proposal_id()

stats.py (8 處):
- 新增 StatsService 封裝快取邏輯
- 移除直接 Redis 存取

audit_logs.py (7 處):
- 新增 AuditLogRepository 封裝 DB 操作
- Router 改用 Repository 層

webhooks.py (2 處):
- 新增 SignalProducerService 封裝 Redis Stream
- 改用 IncidentService.save_to_working_memory()

符合 leWOOOgo 積木化規範:
Router → Service → Repository → DB/Redis

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 13:06:47 +08:00

121 lines
3.2 KiB
Python

"""
Signal Producer Service - Phase 17 P0 Router 層違規修復
======================================================
封裝 Redis Stream 的 Signal 生產邏輯,消除 Router 層直接存取 Redis。
功能:
- XADD 寫入 Redis Stream
- Trace Context 傳遞
符合 leWOOOgo 積木化規範:
- Router -> Service -> Redis
"""
from dataclasses import dataclass
from typing import Any
import structlog
from src.core.redis_client import get_redis
from src.core.telemetry import get_trace_context
from src.utils.timezone import now_taipei
logger = structlog.get_logger(__name__)
# Redis Stream 配置
SIGNAL_STREAM_KEY = "awoooi:signals"
SIGNAL_STREAM_MAXLEN = 10000
@dataclass
class SignalData:
"""Signal 資料結構"""
source: str
alert_name: str
severity: str
namespace: str
target: str
message: str
labels: dict[str, Any] | None = None
annotations: dict[str, Any] | None = None
class SignalProducerService:
"""
Signal 生產者服務
封裝 Redis Stream 的 XADD 操作
"""
async def produce(self, signal: SignalData) -> str:
"""
將 Signal 寫入 Redis Stream
Phase 17: 從 Router 層遷移至 Service 層
使用 Redis Streams (XADD) 實現非同步事件總線:
- MAXLEN ~10000: 限制 Stream 長度,自動裁剪舊訊息
- *: 自動生成 Message ID
Args:
signal: Signal 資料
Returns:
str: Redis Stream Message ID
"""
redis_client = get_redis()
# 組裝 Signal 字典 (所有值必須是字串)
signal_dict = {
"source": signal.source,
"alert_name": signal.alert_name,
"severity": signal.severity,
"namespace": signal.namespace,
"target": signal.target,
"message": signal.message,
"labels": str(signal.labels or {}),
"annotations": str(signal.annotations or {}),
"received_at": now_taipei().isoformat(),
}
# Phase 15.2: 注入 Trace Context
trace_ctx = get_trace_context()
if trace_ctx:
signal_dict["_trace_id"] = trace_ctx.get("trace_id", "")
signal_dict["_span_id"] = trace_ctx.get("span_id", "")
# XADD 寫入 Stream
message_id = await redis_client.xadd(
SIGNAL_STREAM_KEY,
signal_dict,
maxlen=SIGNAL_STREAM_MAXLEN,
approximate=True, # ~MAXLEN 近似裁剪,效能更好
)
logger.info(
"signal_produced",
message_id=message_id,
source=signal.source,
alert_name=signal.alert_name,
severity=signal.severity,
trace_id=trace_ctx.get("trace_id") if trace_ctx else None,
)
return message_id
# =============================================================================
# Singleton
# =============================================================================
_signal_producer: SignalProducerService | None = None
def get_signal_producer() -> SignalProducerService:
"""取得 SignalProducerService 實例 (Singleton)"""
global _signal_producer
if _signal_producer is None:
_signal_producer = SignalProducerService()
return _signal_producer