Refine seller store implementation
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"""商品图片轮播视频:复用后台任务状态机,FFmpeg 在独立 Worker 中执行。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from db import get_session_factory
|
||||
from models import Product, ProductAsset
|
||||
from models.enums import AssetStatus
|
||||
from schemas.video import VideoCreateRequest, VideoCreateResponse, VideoDeleteResponse, VideoJobOut
|
||||
from services.jobs import create_image_job, delete_job, get_job, list_product_jobs, retry_failed_items, video_job_view
|
||||
from services.storage import get_storage
|
||||
from services.storage import local_path
|
||||
from services.video_renderer import slide_seconds_for
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["video"])
|
||||
VIDEO_JOB_KIND = "slideshow_video"
|
||||
|
||||
|
||||
def _job_out(result) -> VideoJobOut:
|
||||
return VideoJobOut(**video_job_view(*result))
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/video-jobs", response_model=VideoCreateResponse)
|
||||
async def create_video_job(product_id: str, req: VideoCreateRequest) -> VideoCreateResponse:
|
||||
try:
|
||||
product_uuid = uuid.UUID(product_id)
|
||||
asset_ids = [uuid.UUID(asset_id) for asset_id in req.asset_ids]
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="商品或图片 ID 不合法") from exc
|
||||
|
||||
async with get_session_factory()() as db:
|
||||
product = await db.get(Product, product_uuid)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="商品不存在")
|
||||
assets = (await db.scalars(
|
||||
select(ProductAsset).where(
|
||||
ProductAsset.product_id == product_uuid,
|
||||
ProductAsset.id.in_(asset_ids),
|
||||
ProductAsset.type == "img",
|
||||
ProductAsset.status == AssetStatus.uploaded,
|
||||
)
|
||||
)).all()
|
||||
by_id = {asset.id: asset for asset in assets}
|
||||
ordered = [by_id.get(asset_id) for asset_id in asset_ids]
|
||||
if any(asset is None or not asset.stored_url for asset in ordered):
|
||||
raise HTTPException(status_code=400, detail="所选图片必须属于当前商品且已完成转存")
|
||||
|
||||
seconds = slide_seconds_for(len(ordered))
|
||||
task = await create_image_job(
|
||||
product_id=product_id,
|
||||
kind=VIDEO_JOB_KIND,
|
||||
priority=5,
|
||||
provider="ffmpeg",
|
||||
model=None,
|
||||
platform="wb_ozon",
|
||||
lang="ru",
|
||||
ratio="3:4",
|
||||
style_set=0,
|
||||
request_payload={
|
||||
"preset": "wb_ozon",
|
||||
"slide_seconds": seconds,
|
||||
"sources": [
|
||||
{"asset_id": str(asset.id), "url": asset.stored_url, "storage_key": asset.current_storage_key}
|
||||
for asset in ordered if asset is not None
|
||||
],
|
||||
},
|
||||
item_payloads=[{"title": "商品轮播视频", "kind": VIDEO_JOB_KIND}],
|
||||
)
|
||||
return VideoCreateResponse(job_id=str(task.id))
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/video-jobs", response_model=list[VideoJobOut])
|
||||
async def list_video_jobs(product_id: str, active_only: bool = False):
|
||||
jobs = await list_product_jobs(product_id, active_only=active_only, kinds=(VIDEO_JOB_KIND,))
|
||||
views: list[VideoJobOut] = []
|
||||
for job in jobs:
|
||||
result = await get_job(str(job.id))
|
||||
if result is not None:
|
||||
views.append(_job_out(result))
|
||||
return views
|
||||
|
||||
|
||||
@router.get("/video-jobs/{job_id}", response_model=VideoJobOut)
|
||||
async def get_video_job(job_id: str):
|
||||
result = await get_job(job_id)
|
||||
if result is None or result[0].kind != VIDEO_JOB_KIND:
|
||||
raise HTTPException(status_code=404, detail="视频任务不存在")
|
||||
return _job_out(result)
|
||||
|
||||
|
||||
@router.post("/video-jobs/{job_id}/cancel", response_model=VideoDeleteResponse)
|
||||
async def cancel_video_job(job_id: str) -> VideoDeleteResponse:
|
||||
# 视频任务不像生图任务需要保留诊断历史:取消后立即从页面和数据库消失。
|
||||
return await _purge_video_job(job_id)
|
||||
|
||||
|
||||
@router.post("/video-jobs/{job_id}/retry", response_model=VideoJobOut)
|
||||
async def retry_video_job(job_id: str):
|
||||
job = await retry_failed_items(job_id)
|
||||
result = await get_job(str(job.id)) if job and job.kind == VIDEO_JOB_KIND else None
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="视频任务不存在")
|
||||
return _job_out(result)
|
||||
|
||||
|
||||
async def _purge_video_job(job_id: str) -> VideoDeleteResponse:
|
||||
result = await get_job(job_id)
|
||||
if result is None or result[0].kind != VIDEO_JOB_KIND:
|
||||
raise HTTPException(status_code=404, detail="视频任务不存在")
|
||||
job, items = result
|
||||
# 先删该任务专属目录;即使 FFmpeg 正在运行,Worker 下一次检查也会发现任务不存在并终止。
|
||||
if job.product_id:
|
||||
await get_storage().delete_prefix(f"products/{job.product_id}/videos/{job.id}/")
|
||||
if not await delete_job(job_id):
|
||||
raise HTTPException(status_code=404, detail="视频任务不存在")
|
||||
return VideoDeleteResponse()
|
||||
|
||||
|
||||
@router.delete("/video-jobs/{job_id}", response_model=VideoDeleteResponse)
|
||||
async def delete_video_job(job_id: str) -> VideoDeleteResponse:
|
||||
"""删除完成/失败视频及其 MP4;任务记录不保留。"""
|
||||
return await _purge_video_job(job_id)
|
||||
|
||||
|
||||
@router.get("/video-jobs/{job_id}/download")
|
||||
async def download_video(job_id: str):
|
||||
result = await get_job(job_id)
|
||||
if result is None or result[0].kind != VIDEO_JOB_KIND or not result[1] or not result[1][0].result_url:
|
||||
raise HTTPException(status_code=404, detail="视频文件不存在")
|
||||
url = result[1][0].result_url
|
||||
path = local_path(url)
|
||||
filename = f"product-video-{job_id}.mp4"
|
||||
if path is not None and Path(path).is_file():
|
||||
return FileResponse(path, media_type="video/mp4", filename=filename)
|
||||
return RedirectResponse(url, status_code=307)
|
||||
Reference in New Issue
Block a user