Refine seller store implementation

This commit is contained in:
R524809
2026-08-31 14:37:28 +08:00
parent 957289f717
commit 2a667b4f3b
27 changed files with 1650 additions and 624 deletions
+16 -1
View File
@@ -20,7 +20,14 @@ from pydantic import BaseModel, Field
from api.proxy import guess_referer
from config import get_settings
from services.storage import ResourceSizeLimitError, download_bytes, local_path, read_local_bytes
from services.storage import (
ResourceDownloadTimeoutError,
ResourceSizeLimitError,
UnsafeRemoteUrlError,
download_bytes,
local_path,
read_local_bytes,
)
router = APIRouter(prefix="/api", tags=["export"])
@@ -111,8 +118,12 @@ async def export_images(req: ExportImagesRequest):
for img in req.images:
try:
data, ctype = await _download(img.url)
except UnsafeRemoteUrlError as exc:
raise HTTPException(status_code=400, detail=f"图片地址不允许导出:{exc}") from exc
except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=f"无法导出「{img.key or '图片'}」:{exc}") from exc
except ResourceDownloadTimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception: # noqa: BLE001
continue # 单张下载失败不中断整包
if not _is_image(img.url, ctype):
@@ -161,8 +172,12 @@ async def export_image(img: ExportImageItem):
"""代理下载单张图片,兼容源站防盗链与本地媒体路径。"""
try:
data, ctype = await _download(img.url)
except UnsafeRemoteUrlError as exc:
raise HTTPException(status_code=400, detail=f"图片地址不允许下载:{exc}") from exc
except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=f"图片文件过大,无法下载:{exc}") from exc
except ResourceDownloadTimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=422, detail="图片下载失败") from exc
if not _is_image(img.url, ctype):
+22 -2
View File
@@ -16,6 +16,7 @@ from models.enums import AssetStatus, Stage
from schemas.collection import MaterialsRequest, MaterialsResponse, TextMaterial
from schemas.suite import SuiteOut, WatermarkOptions
from services.image_assets import WatermarkUpdate, get_image_asset_service
from services.storage import ImageValidationError, ResourceSizeLimitError, normalize_image_bytes
from services.jobs import create_image_job, get_job, list_product_jobs, suite_view
from config import get_settings
@@ -258,6 +259,15 @@ async def upload_material_bytes(
raise HTTPException(status_code=413, detail=f"图片文件过大,单张最多 {limit // 1024 // 1024} MB")
chunks.append(chunk)
data = b"".join(chunks)
if type != "img":
raise HTTPException(status_code=400, detail="该接口目前仅支持上传图片素材")
try:
normalized = normalize_image_bytes(data, max_bytes=limit)
except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except ImageValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
asset = ProductAsset(
product_id=product.id,
group_key=group_key,
@@ -272,13 +282,23 @@ async def upload_material_bytes(
# 直接转存为素材唯一原图(current 与 original 初始指向同一对象)
image_assets = get_image_asset_service()
try:
await image_assets.save_original(asset, data, file.content_type or "")
await image_assets.save_original(
asset,
normalized.data,
normalized.content_type,
max_bytes=limit,
)
asset.status = AssetStatus.uploaded
except Exception as exc: # noqa: BLE001
asset.status = AssetStatus.failed
asset.error = str(exc)[:500]
await db.commit()
return {"asset_id": str(asset.id), "status": asset.status.value}
return {
"asset_id": str(asset.id),
"status": asset.status.value,
"width": asset.width,
"height": asset.height,
}
@router.get("/products/{product_id}/fingerprints")
+10 -1
View File
@@ -8,7 +8,12 @@ from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Query, Response
from services.storage import ResourceSizeLimitError, download_bytes
from services.storage import (
ResourceDownloadTimeoutError,
ResourceSizeLimitError,
UnsafeRemoteUrlError,
download_bytes,
)
router = APIRouter(prefix="/api", tags=["proxy"])
@@ -40,8 +45,12 @@ async def proxy_image(url: str = Query(..., description="源站图片 URL")):
raise HTTPException(status_code=400, detail="仅支持 http/https URL")
try:
data, ctype = await download_bytes(url, referer=guess_referer(url))
except UnsafeRemoteUrlError as exc:
raise HTTPException(status_code=400, detail=f"图片地址不允许代理:{exc}") from exc
except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=f"图片文件过大,无法代理:{exc}") from exc
except ResourceDownloadTimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"图片拉取失败: {exc}") from exc
if not ctype.startswith("image/"):
+140
View File
@@ -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)