- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段); 修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品 - dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开; settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch - 新增用户管理(users API/admin service/改密页) + alembic 022/023/024 - 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example - A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
201 lines
7.1 KiB
Python
201 lines
7.1 KiB
Python
"""
|
||
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=<download-token>(前端 window.open 用,C1坑修复)
|
||
2. Authorization: Bearer <JWT>(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,
|
||
)
|