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,97 @@
"""
app/api/v1/stream.py — SSE ticket 签发 + SSE 流路由
安全红线倩倩姐2026-06-08拍板SSE 认证用一次性短票 ticket禁止 ?token= 直传 JWT。
流程:
1. FE 带 JWT 调 POST /tasks/{id}/sse-ticket → 拿 ticket60s有效一次性
2. FE 用 ticket 建 EventSource: GET /tasks/{id}/stream?ticket=<ticket>
3. BE 验 ticket原子 getdel用后即失效换不了其他接口
"""
import logging
from typing import Annotated
import redis as sync_redis
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import get_db
from app.core.response import ok, raise_not_found, raise_unauthorized
from app.core.sse_ticket import consume_ticket, issue_ticket
from app.middleware.workspace_guard import CurrentUser, get_current_user
from app.models.task import GenerationTask
from app.utils.sse_utils import stream_events
logger = logging.getLogger(__name__)
router = APIRouter()
settings = get_settings()
# ── 签发接口(用 JWT 换 ticket────────────────────────────
@router.post("/tasks/{task_id}/sse-ticket")
def create_sse_ticket(
task_id: int,
current_user: Annotated[CurrentUser, Depends(get_current_user)],
db: Session = Depends(get_db),
):
"""
签发一次性 SSE ticket60s 有效,仅对该 task 有效)。
FE 拿到 ticket 后立即建 EventSource不要存起来复用。
"""
task = (
db.query(GenerationTask)
.filter(
GenerationTask.id == task_id,
GenerationTask.workspace_id == current_user.workspace_id,
)
.first()
)
if not task:
raise_not_found("任务不存在")
r = sync_redis.from_url(settings.REDIS_URL, decode_responses=True)
ticket = issue_ticket(r, task_id, current_user.workspace_id)
return ok({"ticket": ticket, "expires_in": 60})
# ── SSE 流ticket 认证,绝不传 JWT─────────────────────
@router.get("/tasks/{task_id}/stream")
async def task_stream(
task_id: int,
ticket: str = Query(...),
last_seq: int = Query(default=0),
):
"""
SSE 实时进度推送。
ticket 验证:原子 getdel用后即失效无法重放、无法访问其他接口。
支持断线重连补发(?last_seq=<上次收到的seq>)。
断线重连时 FE 须重新调 sse-ticket 换新 ticket。
"""
r_sync = sync_redis.from_url(settings.REDIS_URL, decode_responses=True)
result = consume_ticket(r_sync, ticket)
if not result:
raise_unauthorized("ticket 无效或已过期")
verified_task_id, workspace_id = result
if verified_task_id != task_id:
raise_unauthorized("ticket 与任务不匹配")
redis_async = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
return StreamingResponse(
stream_events(
redis_client=redis_async,
task_id=task_id,
workspace_id=workspace_id,
last_seq=last_seq,
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)