""" app/utils/sse_utils.py — SSE 工具函数(按 Lead 规范路径) 补发历史事件、event_seq 去重、Redis pub/sub 推送。 供 api/v1/stream.py 和 AIE worker 共用。 """ import asyncio import json import logging from typing import Any, AsyncGenerator from app.core.config import get_settings logger = logging.getLogger(__name__) settings = get_settings() # SSE 事件类型(契约§2全列) SSE_EVENT_TYPES = frozenset({ "task_started", "analyze_done", "text_progress", "text_candidate", "image_progress", "image_candidate", "flywheel_injected", "batch_failed", "task_done", "error", "heartbeat", }) _HISTORY_KEY_TPL = "sse:task:{task_id}:events" _CHANNEL_TPL = "sse:task:{task_id}" _HISTORY_TTL_SECONDS = 3600 # 历史事件保留 1h def format_sse(event: str, data: dict, seq: int | None = None) -> str: """格式化单条 SSE 消息(text/event-stream 格式)。""" payload = json.dumps(data, ensure_ascii=False) lines = [f"event: {event}", f"data: {payload}"] if seq is not None: lines.append(f"id: {seq}") return "\n".join(lines) + "\n\n" async def push_event( redis_client, task_id: int, workspace_id: int, event: str, data: dict[str, Any], seq: int, ) -> None: """ 推送事件到 Redis: 1. 追加到历史 list(供断线重连补发) 2. Publish 到 channel(供在线客户端实时收) """ if event not in SSE_EVENT_TYPES: logger.warning("Unknown SSE event type: %s", event) record = {"event": event, "data": data, "seq": seq, "workspace_id": workspace_id} payload = json.dumps(record, ensure_ascii=False) hist_key = _HISTORY_KEY_TPL.format(task_id=task_id) channel = _CHANNEL_TPL.format(task_id=task_id) await redis_client.rpush(hist_key, payload) await redis_client.expire(hist_key, _HISTORY_TTL_SECONDS) await redis_client.publish(channel, payload) async def get_history_events( redis_client, task_id: int, after_seq: int ) -> list[dict]: """取 task 历史事件中 seq > after_seq 的部分(断线重连补发)。""" hist_key = _HISTORY_KEY_TPL.format(task_id=task_id) try: raw_list = await redis_client.lrange(hist_key, 0, -1) result = [] for raw in raw_list: ev = json.loads(raw) if ev.get("seq", 0) > after_seq: result.append(ev) return result except Exception as exc: logger.warning("get_history_events failed task=%s: %s", task_id, exc) return [] async def stream_events( redis_client, task_id: int, workspace_id: int, last_seq: int = 0, heartbeat_interval: int = 25, ) -> AsyncGenerator[str, None]: """ 主 SSE 生成器: 1. 先补发 last_seq 之后的历史事件 2. 再订阅 Redis channel 实时推新事件 3. 每 heartbeat_interval 秒推一次保活 heartbeat """ # 1. 补发历史 history = await get_history_events(redis_client, task_id, last_seq) for ev in history: yield format_sse(ev["event"], ev["data"], ev.get("seq")) # 2. 实时订阅 pubsub = redis_client.pubsub() channel = _CHANNEL_TPL.format(task_id=task_id) await pubsub.subscribe(channel) elapsed = 0 try: while True: msg = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0) if msg and msg["type"] == "message": ev = json.loads(msg["data"]) if ev.get("workspace_id") != workspace_id: continue # 防越权订阅他人任务 yield format_sse(ev["event"], ev["data"], ev.get("seq")) if ev["event"] in ("task_done", "error"): break else: elapsed += 1 if elapsed >= heartbeat_interval: elapsed = 0 yield format_sse("heartbeat", {"ts": asyncio.get_event_loop().time()}) finally: await pubsub.unsubscribe(channel)