消除 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>
121 lines
3.2 KiB
Python
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
|