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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:30:22 +08:00

98 lines
3.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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",
},
)