baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
7
backend/app/utils/README.md
Normal file
7
backend/app/utils/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# app/utils/
|
||||
|
||||
工具层占位:
|
||||
- fernet_utils.py # Fernet加密/解密(FERNET_KEY走环境变量,绝不进代码库)
|
||||
- sse_utils.py # SSE推送工具(补发历史事件,前端按event_seq去重)
|
||||
- pagination.py # 统一分页工具
|
||||
- ai_usage_logger.py # AI调用用量记录(每次调用记usage,归因到个人key)
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
49
backend/app/utils/fernet_utils.py
Normal file
49
backend/app/utils/fernet_utils.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
app/utils/fernet_utils.py — Fernet 加解密工具(按 Lead 规范路径)
|
||||
FERNET_KEY 走环境变量,绝不进代码库(基石B)。
|
||||
此模块是 app/core/security.py 中 Fernet 功能的独立导出,
|
||||
供 AIE / worker 层直接 import,无需依赖 FastAPI 上下文。
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_fernet_instance: Fernet | None = None
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
global _fernet_instance
|
||||
if _fernet_instance is None:
|
||||
settings = get_settings()
|
||||
_fernet_instance = Fernet(settings.FERNET_KEY.encode())
|
||||
return _fernet_instance
|
||||
|
||||
|
||||
def encrypt_key(plain_key: str) -> str:
|
||||
"""
|
||||
加密 API Key,返回密文字符串。
|
||||
调用方绝不打印 plain_key(基石B)。
|
||||
"""
|
||||
return _get_fernet().encrypt(plain_key.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_key(encrypted_key: str) -> str:
|
||||
"""
|
||||
解密 API Key。只在 Celery worker 内部调用。
|
||||
解密结果只活在调用函数的局部变量,不落盘、不打日志。
|
||||
"""
|
||||
try:
|
||||
return _get_fernet().decrypt(encrypted_key.encode()).decode()
|
||||
except InvalidToken:
|
||||
logger.error("Fernet decryption failed: token invalid or key rotated")
|
||||
raise ValueError("API key decryption failed")
|
||||
|
||||
|
||||
def mask_key(plain_key: str) -> str:
|
||||
"""只返回后4位(展示用,不暴露完整 key)。"""
|
||||
return plain_key[-4:] if len(plain_key) >= 4 else "****"
|
||||
122
backend/app/utils/sse_utils.py
Normal file
122
backend/app/utils/sse_utils.py
Normal 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)
|
||||
Reference in New Issue
Block a user