baseline: Clover 独立仓库首次基线提交

将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。
当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包),
M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-16 11:30:22 +08:00
commit 6a2632da70
253 changed files with 27467 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
"""
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)