feat: 优化图片资源删除管理,生图任务列表管理
This commit is contained in:
+165
-19
@@ -2,9 +2,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Form
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -12,8 +14,38 @@ from db import get_db, get_session_factory
|
||||
from models import Product, ProductAsset
|
||||
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.jobs import create_image_job, get_job, list_product_jobs, suite_view
|
||||
from config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["collection"])
|
||||
log = logging.getLogger("materials")
|
||||
|
||||
|
||||
class ApplyWatermarkRequest(BaseModel):
|
||||
"""对已落库图片批量应用水印;始终基于 original 重新合成。"""
|
||||
|
||||
asset_ids: list[str] = Field(min_length=1, max_length=50)
|
||||
watermark: WatermarkOptions
|
||||
|
||||
|
||||
class ApplyWatermarkResponse(BaseModel):
|
||||
"""批量水印已进入后台队列;前端以 job_id 查询逐图进度。"""
|
||||
|
||||
job_id: str
|
||||
total: int
|
||||
|
||||
|
||||
class RemoveWatermarkResponse(BaseModel):
|
||||
"""同步移除水印的逐项处理结果。"""
|
||||
|
||||
updated_ids: list[str]
|
||||
failed: list[dict[str, str]]
|
||||
|
||||
|
||||
class RemoveWatermarkRequest(BaseModel):
|
||||
asset_ids: list[str] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
def _parse_number(text: str | None) -> float | None:
|
||||
@@ -115,6 +147,11 @@ async def create_materials(
|
||||
background: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> MaterialsResponse:
|
||||
if len(req.images) > get_settings().collection_max_images:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"一次最多采集 {get_settings().collection_max_images} 个素材,请分批上报",
|
||||
)
|
||||
product = await _get_or_create_product(db, req)
|
||||
_apply_texts(product, req.texts)
|
||||
|
||||
@@ -176,9 +213,7 @@ async def create_materials(
|
||||
|
||||
async def process_product_assets(product_id: str) -> None:
|
||||
"""后台:下载 pending 素材 → 转存 storage。失败逐张标记,不中断。"""
|
||||
from services.storage import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
image_assets = get_image_asset_service()
|
||||
async with get_session_factory()() as db:
|
||||
assets = (await db.scalars(
|
||||
select(ProductAsset).where(
|
||||
@@ -190,8 +225,7 @@ async def process_product_assets(product_id: str) -> None:
|
||||
a.status = AssetStatus.downloading
|
||||
await db.commit()
|
||||
try:
|
||||
stored = await storage.save_from_url(a.source_url, key_prefix="assets")
|
||||
a.stored_url = stored
|
||||
await image_assets.save_original_from_url(a, a.source_url)
|
||||
a.status = AssetStatus.uploaded
|
||||
except Exception as exc: # noqa: BLE001
|
||||
a.status = AssetStatus.failed
|
||||
@@ -213,7 +247,17 @@ async def upload_material_bytes(
|
||||
product = await db.get(Product, UUID(product_id))
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="商品不存在")
|
||||
data = await file.read()
|
||||
limit = get_settings().image_upload_max_bytes
|
||||
if file.size is not None and file.size > limit:
|
||||
raise HTTPException(status_code=413, detail=f"图片文件过大,单张最多 {limit // 1024 // 1024} MB")
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
if total > limit:
|
||||
raise HTTPException(status_code=413, detail=f"图片文件过大,单张最多 {limit // 1024 // 1024} MB")
|
||||
chunks.append(chunk)
|
||||
data = b"".join(chunks)
|
||||
asset = ProductAsset(
|
||||
product_id=product.id,
|
||||
group_key=group_key,
|
||||
@@ -225,11 +269,10 @@ async def upload_material_bytes(
|
||||
)
|
||||
db.add(asset)
|
||||
await db.flush()
|
||||
# 直接转存字节
|
||||
from services.storage import get_storage
|
||||
storage = get_storage()
|
||||
# 直接转存为素材唯一原图(current 与 original 初始指向同一对象)
|
||||
image_assets = get_image_asset_service()
|
||||
try:
|
||||
asset.stored_url = await storage.save_bytes(data, f"assets/{asset.id}", file.content_type or "")
|
||||
await image_assets.save_original(asset, data, file.content_type or "")
|
||||
asset.status = AssetStatus.uploaded
|
||||
except Exception as exc: # noqa: BLE001
|
||||
asset.status = AssetStatus.failed
|
||||
@@ -269,20 +312,16 @@ async def is_collected(
|
||||
|
||||
@router.delete("/assets/{asset_id}")
|
||||
async def delete_asset(asset_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""删除一条素材记录(DB 行 + 本地文件;七牛等远程存储仅删引用)。"""
|
||||
from services.storage import local_path
|
||||
"""删除一条素材记录及其 original/current 七牛对象。"""
|
||||
|
||||
asset = await db.get(ProductAsset, UUID(asset_id))
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=404, detail="素材不存在")
|
||||
|
||||
if asset.stored_url:
|
||||
path = local_path(asset.stored_url)
|
||||
if path is not None and path.is_file():
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError: # noqa: BLE001
|
||||
pass # 文件删除失败不阻断记录删除
|
||||
try:
|
||||
await get_image_asset_service().delete_asset_media(asset)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"图片存储删除失败,素材未删除:{exc}") from exc
|
||||
|
||||
product_id = asset.product_id
|
||||
group_key = asset.group_key
|
||||
@@ -298,3 +337,110 @@ async def delete_asset(asset_id: str, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
await db.commit()
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.post("/assets/watermark", response_model=ApplyWatermarkResponse)
|
||||
async def apply_assets_watermark(
|
||||
req: ApplyWatermarkRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApplyWatermarkResponse:
|
||||
"""创建批量水印后台任务;Worker 从原图逐张合成并切换当前展示版。"""
|
||||
if not req.watermark.enabled:
|
||||
raise HTTPException(status_code=400, detail="请先开启水印")
|
||||
|
||||
try:
|
||||
asset_ids = list(dict.fromkeys(UUID(asset_id) for asset_id in req.asset_ids))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="包含非法素材 ID") from exc
|
||||
|
||||
assets = (await db.scalars(select(ProductAsset).where(ProductAsset.id.in_(asset_ids)))).all()
|
||||
by_id = {asset.id: asset for asset in assets}
|
||||
missing = [str(asset_id) for asset_id in asset_ids if asset_id not in by_id]
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail=f"素材不存在:{', '.join(missing[:3])}")
|
||||
|
||||
product_ids = {asset.product_id for asset in assets}
|
||||
if len(product_ids) != 1:
|
||||
raise HTTPException(status_code=400, detail="一次批量水印只能处理同一商品的素材")
|
||||
product_id = str(next(iter(product_ids)))
|
||||
task = await create_image_job(
|
||||
product_id=product_id,
|
||||
kind="watermark_batch",
|
||||
# 水印在后台低优先级执行,不阻塞单张编辑和套图生成。
|
||||
priority=-10,
|
||||
provider="local",
|
||||
model=None,
|
||||
platform="ozon",
|
||||
lang="",
|
||||
ratio="",
|
||||
style_set=0,
|
||||
request_payload={"watermark": req.watermark.model_dump()},
|
||||
item_payloads=[
|
||||
{
|
||||
"position": index,
|
||||
"kind": "watermark",
|
||||
"title": f"水印:{str(asset.id)[:8]}",
|
||||
"asset_id": str(asset.id),
|
||||
# 任务排队时冻结当前版本;若同图被其他操作更新,Worker 不会覆盖新版本。
|
||||
"expected_current_storage_key": asset.current_storage_key,
|
||||
"expected_current_url": asset.stored_url,
|
||||
}
|
||||
for index, asset in enumerate(by_id[asset_id] for asset_id in asset_ids)
|
||||
],
|
||||
)
|
||||
return ApplyWatermarkResponse(job_id=str(task.id), total=task.total)
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/watermark-jobs", response_model=list[SuiteOut])
|
||||
async def list_watermark_jobs(product_id: str, active_only: bool = False):
|
||||
"""试算页刷新后恢复正在执行的批量水印任务。"""
|
||||
jobs = await list_product_jobs(product_id, active_only=active_only, kinds=("watermark_batch",))
|
||||
views: list[SuiteOut] = []
|
||||
for job in jobs:
|
||||
result = await get_job(str(job.id))
|
||||
if result is not None:
|
||||
views.append(SuiteOut(**suite_view(*result)))
|
||||
return views
|
||||
|
||||
|
||||
@router.post("/assets/watermark/remove", response_model=RemoveWatermarkResponse)
|
||||
async def remove_assets_watermark(
|
||||
req: RemoveWatermarkRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RemoveWatermarkResponse:
|
||||
"""批量移除水印:current 切回 original,并删除当前水印对象。"""
|
||||
try:
|
||||
asset_ids = list(dict.fromkeys(UUID(asset_id) for asset_id in req.asset_ids))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="包含非法素材 ID") from exc
|
||||
|
||||
assets = (await db.scalars(select(ProductAsset).where(ProductAsset.id.in_(asset_ids)))).all()
|
||||
by_id = {asset.id: asset for asset in assets}
|
||||
missing = [str(asset_id) for asset_id in asset_ids if asset_id not in by_id]
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail=f"素材不存在:{', '.join(missing[:3])}")
|
||||
|
||||
image_assets = get_image_asset_service()
|
||||
updated_ids: list[str] = []
|
||||
failed: list[dict[str, str]] = []
|
||||
obsolete: list[WatermarkUpdate] = []
|
||||
for asset_id in asset_ids:
|
||||
asset = by_id[asset_id]
|
||||
if asset.type != "img":
|
||||
failed.append({"asset_id": str(asset_id), "error": "仅支持图片素材"})
|
||||
continue
|
||||
if not asset.watermark_enabled:
|
||||
continue
|
||||
try:
|
||||
obsolete.append(await image_assets.remove_watermark(asset))
|
||||
updated_ids.append(str(asset_id))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed.append({"asset_id": str(asset_id), "error": str(exc)[:200]})
|
||||
|
||||
await db.commit()
|
||||
for update in obsolete:
|
||||
try:
|
||||
await image_assets.delete_key(update.obsolete_key)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("水印已移除,但旧水印对象删除失败:%s", update.obsolete_key)
|
||||
return RemoveWatermarkResponse(updated_ids=updated_ids, failed=failed)
|
||||
|
||||
Reference in New Issue
Block a user