447 lines
17 KiB
Python
447 lines
17 KiB
Python
"""采集入库:插件上传文本 + 图片 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.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)
|
||
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, data, file.content_type or "")
|
||
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}
|
||
|
||
|
||
@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)
|