Files
ozon-seller-kit/server/api/materials.py
T
2026-08-31 14:37:28 +08:00

467 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""采集入库:插件上传文本 + 图片 URL,落库后异步转存。"""
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
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.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
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:
"""'1 290 ₽' / '3.5 кг' / '48*18*25' → 1290.0 / 3.5 / 48"""
if not text:
return None
m = re.search(r"(\d+(?:[.,]\d+)?)", text.replace(" ", "").replace(",", "."))
return float(m.group(1)) if m else None
def _find_param(pairs: list[dict] | None, keys: list[str]) -> str | None:
for p in pairs or []:
k = (p.get("key") or "").lower()
if any(kw in k for kw in keys):
return p.get("value")
return None
def _apply_texts(product: Product, texts: list[TextMaterial]) -> None:
raw = dict(product.raw or {})
raw_texts: list[dict] = list(raw.get("texts") or [])
for t in texts:
raw_texts.append({"kind": t.kind, "content": t.content, "pairs": t.pairs})
if t.kind == "title" and t.content and not product.name:
product.name = t.content
raw["title"] = t.content
elif t.kind == "price":
raw["price"] = t.content
num = _parse_number(t.content)
if num is not None and (product.price is None or product.price == 0):
product.price = num
elif t.kind == "params":
raw["params"] = t.pairs
_apply_weight_dims(product, t.pairs)
elif t.kind == "selling_point":
raw["sellingPoints"] = t.content
elif t.kind == "desc":
raw["desc"] = t.content
if not product.description:
product.description = t.content
elif t.kind == "brand":
raw["brand"] = t.content
raw["texts"] = raw_texts
product.raw = raw
def _apply_weight_dims(product: Product, pairs: list[dict] | None) -> None:
"""从参数表里解析「包装重量 / 包装尺寸(长宽高)」,统一换算成克 / 毫米回填。"""
weight = _find_param(pairs, ["包装重量", "重量", "вес"])
if weight is not None:
num = _parse_number(weight)
if num is not None:
is_kg = any(u in weight.lower() for u in ("кг", "kg"))
product.weight = num * 1000 if is_kg else num # 统一为克
product.weight_unit = "g"
l = _find_param(pairs, ["包装长度", "长度", "длина"])
w = _find_param(pairs, ["包装宽度", "宽度", "ширина"])
h = _find_param(pairs, ["包装高度", "高度", "высота"])
if l or w or h:
combined = (l or "") + (w or "") + (h or "")
factor = 1 if any(u in combined.lower() for u in ("мм", "mm")) else 10 # 厘米→毫米
product.depth = (_parse_number(l) or 0) * factor if l else None
product.width = (_parse_number(w) or 0) * factor if w else None
product.height = (_parse_number(h) or 0) * factor if h else None
product.dimension_unit = "mm"
else:
dim = _find_param(pairs, ["包装尺寸", "размер", "габарит", "尺寸"])
if dim is not None:
nums = re.findall(r"\d+(?:[.,]\d+)?", dim.replace(",", "."))
if len(nums) >= 3:
factor = 1 if any(u in dim.lower() for u in ("мм", "mm")) else 10
product.depth = float(nums[0]) * factor
product.width = float(nums[1]) * factor
product.height = float(nums[2]) * factor
product.dimension_unit = "mm"
async def _get_or_create_product(db: AsyncSession, req: MaterialsRequest) -> Product:
if req.product_id:
product = await db.get(Product, UUID(req.product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
return product
product = Product(
stage=Stage.collected,
source_platform=req.source.platform,
source_item_id=req.source.itemId,
source_url=req.source.url,
)
db.add(product)
await db.flush()
return product
@router.post("/materials", response_model=MaterialsResponse)
async def create_materials(
req: MaterialsRequest,
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)
# 采集溯源(追加来源)
if not product.source_url:
product.source_url = req.source.url
if not product.source_platform:
product.source_platform = req.source.platform
# 去重 + 建素材
existing = set()
if req.images:
rows = (await db.execute(
select(ProductAsset.dedupe_key).where(
ProductAsset.product_id == product.id,
ProductAsset.dedupe_key.isnot(None),
)
)).scalars().all()
existing = {k for k in rows if k}
queued, skipped = 0, 0
for img in req.images:
if img.dedupeKey and img.dedupeKey in existing:
skipped += 1
continue
db.add(ProductAsset(
product_id=product.id,
group_key=img.groupKey,
variant_name=img.variantName,
sort_order=img.index,
type=img.type,
source_url=img.url,
status=AssetStatus.pending,
dedupe_key=img.dedupeKey,
))
if img.dedupeKey:
existing.add(img.dedupeKey)
queued += 1
# 更新分组计数
counts: dict = {}
for a in await db.scalars(select(ProductAsset).where(ProductAsset.product_id == product.id)):
counts[a.group_key] = counts.get(a.group_key, 0) + 1
product.asset_counts = counts
product.stage = Stage.collected if product.stage == Stage.collected else product.stage
await db.commit()
await db.refresh(product)
if queued:
background.add_task(process_product_assets, str(product.id))
return MaterialsResponse(
product_id=str(product.id),
stage=product.stage.value,
assets_queued=queued,
assets_skipped=skipped,
)
async def process_product_assets(product_id: str) -> None:
"""后台:下载 pending 素材 → 转存 storage。失败逐张标记,不中断。"""
image_assets = get_image_asset_service()
async with get_session_factory()() as db:
assets = (await db.scalars(
select(ProductAsset).where(
ProductAsset.product_id == UUID(product_id),
ProductAsset.status == AssetStatus.pending,
)
)).all()
for a in assets:
a.status = AssetStatus.downloading
await db.commit()
try:
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
a.error = str(exc)[:500]
await db.commit()
@router.post("/materials/bytes")
async def upload_material_bytes(
background: BackgroundTasks,
product_id: str = Form(...),
group_key: str = Form("main"),
variant_name: str | None = Form(None),
sort_order: int = Form(0),
type: str = Form("img"),
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db)
):
product = await db.get(Product, UUID(product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
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)
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,
variant_name=variant_name,
sort_order=sort_order,
type=type,
source_url="",
status=AssetStatus.pending,
)
db.add(asset)
await db.flush()
# 直接转存为素材唯一原图(current 与 original 初始指向同一对象)
image_assets = get_image_asset_service()
try:
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,
"width": asset.width,
"height": asset.height,
}
@router.get("/products/{product_id}/fingerprints")
async def product_fingerprints(
product_id: str,
db: AsyncSession = Depends(get_db)
):
rows = (await db.scalars(
select(ProductAsset.dedupe_key).where(
ProductAsset.product_id == UUID(product_id),
ProductAsset.dedupe_key.isnot(None),
)
)).all()
return {"dedupe_keys": list(rows)}
@router.get("/collected")
async def is_collected(
platform: str,
itemId: str,
db: AsyncSession = Depends(get_db)
):
rows = (await db.execute(
select(Product).where(
Product.source_platform == platform,
Product.source_item_id == itemId,
)
)).scalars().all()
return {"collected": len(rows) > 0, "count": len(rows)}
@router.delete("/assets/{asset_id}")
async def delete_asset(asset_id: str, db: AsyncSession = Depends(get_db)):
"""删除一条素材记录及其 original/current 七牛对象。"""
asset = await db.get(ProductAsset, UUID(asset_id))
if asset is None:
raise HTTPException(status_code=404, detail="素材不存在")
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
await db.delete(asset)
# 同步修正 asset_counts(generated 计数由生图回写时累加)
product = await db.get(Product, product_id)
if product is not None:
counts = dict(product.asset_counts or {})
if group_key in counts:
counts[group_key] = max(0, int(counts.get(group_key) or 0) - 1)
product.asset_counts = counts
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)