""" app/api/v1/delivery.py — 交付包路由 POST /tasks/{id}/package — 生成达人素材交付包 GET /delivery-packages/{id}/download — 查询下载状态(无副作用) POST /delivery-packages/{id}/download-token — 签发60s一次性下载令牌(C1坑修复) POST /delivery-packages/{id}/mark-downloaded — 显式标记已下载 GET /delivery-packages/{id}/download-file?token= — 带令牌下载,前端可用 window.open """ import logging from typing import Annotated from fastapi import APIRouter, Depends, Header, Query from fastapi.responses import FileResponse from pydantic import BaseModel from sqlalchemy.orm import Session from app.core.database import get_db from app.core.response import ok, raise_not_found from app.middleware.workspace_guard import CurrentUser, require_write_permission from app.models.task import DeliveryPackage, GenerationTask logger = logging.getLogger(__name__) router = APIRouter(tags=["delivery"]) class PackageRequest(BaseModel): note_ids: list[int] = [] # 可选,指定要打包的内容 def _fmt_package(pkg: DeliveryPackage) -> dict: return { "id": pkg.id, "status": pkg.status, "download_url": pkg.download_url, "expires_at": pkg.expires_at.isoformat() if pkg.expires_at else None, } @router.post("/tasks/{task_id}/package") def create_package( task_id: int, body: PackageRequest, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, db: Session = Depends(get_db), ): """ 生成达人素材交付包。 任务推入 Celery build_delivery_package 队列(只传 package_id)。 """ task = db.query(GenerationTask).filter( GenerationTask.id == task_id, GenerationTask.workspace_id == current_user.workspace_id, ).first() if not task: raise_not_found("任务不存在") if task.status not in ("approved", "pending_selection"): from app.core.response import raise_business raise_business("任务尚未生成完成,无法打包") pkg = DeliveryPackage( workspace_id=current_user.workspace_id, task_id=task_id, status="pending", ) db.add(pkg) db.commit() db.refresh(pkg) # 推 Celery 队列,只传 package_id(基石B思路:不传任何敏感信息) from app.workers.tasks import build_delivery_package build_delivery_package.delay(pkg.id) return ok({"delivery_package_id": pkg.id, "status": "pending"}) @router.get("/delivery-packages/{package_id}/download") def get_package_download( package_id: int, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, db: Session = Depends(get_db), ): """查询交付包下载状态。GET 只读,实际下载由 download-file 完成。""" pkg = db.query(DeliveryPackage).filter( DeliveryPackage.id == package_id, DeliveryPackage.workspace_id == current_user.workspace_id, ).first() if not pkg: raise_not_found("交付包不存在") return ok(_fmt_package(pkg)) @router.post("/delivery-packages/{package_id}/download-token") def create_download_token( package_id: int, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, db: Session = Depends(get_db), ): """ C1坑修复:签发一次性下载令牌(60s有效)。 前端先用 JWT 调此接口,再用 token 直接 window.open/fetch,无需在URL里传JWT。 """ import secrets import redis as sync_redis from app.core.config import get_settings pkg = db.query(DeliveryPackage).filter( DeliveryPackage.id == package_id, DeliveryPackage.workspace_id == current_user.workspace_id, ).first() if not pkg: raise_not_found("交付包不存在") if pkg.status not in ("ready", "downloaded"): from app.core.response import raise_business raise_business("交付包尚未准备好") token = secrets.token_hex(32) r = sync_redis.from_url(get_settings().REDIS_URL, decode_responses=True) r.setex(f"dl_token:{token}", 60, str(package_id)) return ok({"token": token, "expires_in": 60}) @router.post("/delivery-packages/{package_id}/mark-downloaded") def mark_package_downloaded( package_id: int, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, db: Session = Depends(get_db), ): """显式标记交付包已下载。所有 GET 下载接口保持只读。""" pkg = db.query(DeliveryPackage).filter( DeliveryPackage.id == package_id, DeliveryPackage.workspace_id == current_user.workspace_id, ).first() if not pkg: raise_not_found("交付包不存在") if pkg.status == "ready": pkg.status = "downloaded" db.commit() db.refresh(pkg) return ok(_fmt_package(pkg)) @router.get("/delivery-packages/{package_id}/download-file") def download_package_file( package_id: int, token: str = Query(default=""), authorization: str = Header(default=""), db: Session = Depends(get_db), ): """ 直接下载交付包 zip 文件(FileResponse)。 支持两种认证: 1. ?token=(前端 window.open 用,C1坑修复) 2. Authorization: Bearer (API 调用用) """ import os import redis as sync_redis from app.core.config import get_settings from app.core.security import decode_access_token import jwt as pyjwt # token 认证(一次性,60s有效,跳过JWT) if token: r = sync_redis.from_url(get_settings().REDIS_URL, decode_responses=True) stored = r.getdel(f"dl_token:{token}") if not stored or int(stored) != package_id: from app.core.response import raise_business raise_business("下载令牌无效或已过期") pkg = db.query(DeliveryPackage).filter(DeliveryPackage.id == package_id).first() else: # JWT 认证 if not authorization or not authorization.startswith("Bearer "): from app.core.response import raise_unauthorized raise_unauthorized("缺少认证信息") try: payload = decode_access_token(authorization.split(" ", 1)[1]) except (pyjwt.PyJWTError, Exception): from app.core.response import raise_unauthorized raise_unauthorized("Token 无效") workspace_id = int(payload["current_workspace_id"]) pkg = db.query(DeliveryPackage).filter( DeliveryPackage.id == package_id, DeliveryPackage.workspace_id == workspace_id, ).first() if not pkg: raise_not_found("交付包不存在") if pkg.status not in ("ready", "downloaded"): from app.core.response import raise_business raise_business("交付包尚未准备好,请稍后重试") if not pkg.package_path or not os.path.exists(pkg.package_path): from app.core.response import raise_business raise_business("交付包文件不存在,请重新打包") filename = os.path.basename(pkg.package_path) return FileResponse( path=pkg.package_path, media_type="application/zip", filename=filename, )