"""采集入库:插件上传文本 + 图片 URL,落库后异步转存。""" from __future__ import annotations import re from uuid import UUID from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from db import get_db, get_session_factory from models import ( Product, ProductAsset, STATUS_PENDING, STATUS_DOWNLOADING, STATUS_OK, STATUS_FAILED, STAGE_COLLECTED, ) from schemas import MaterialsRequest, MaterialsResponse, TextMaterial from services import storage router = APIRouter(prefix="/api", tags=["collection"]) def _parse_number(text: str | None) -> float | None: """'1 290 ₽' / '¥36.80' → 1290.0 / 36.8""" if not text: return None m = re.search(r"(\d+(?:[.,]\d+)?)", text.replace(" ", "").replace(",", ".")) return float(m.group(1)) if m else 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" and t.pairs: # 与已有参数按 key 并集合并(跨页追加时同一参数不重复) merged = {p["key"]: p["value"] for p in (raw.get("params") or [])} for p in t.pairs: merged.setdefault(p["key"], p["value"]) raw["params"] = [{"key": k, "value": v} for k, v in merged.items()] 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 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: 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[str] = 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=STATUS_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 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), assets_queued=queued, assets_skipped=skipped) async def process_product_assets(product_id: str) -> None: """后台:下载 pending 素材 → 转存本地 media。失败逐张标记,不中断。""" async with get_session_factory()() as db: assets = (await db.scalars( select(ProductAsset).where( ProductAsset.product_id == UUID(product_id), ProductAsset.status == STATUS_PENDING, ProductAsset.type == "img", ) )).all() for a in assets: a.status = STATUS_DOWNLOADING await db.commit() try: a.stored_url = await storage.save_from_url(a.source_url, key_prefix="assets") a.status = STATUS_OK except Exception as exc: # noqa: BLE001 a.status = STATUS_FAILED a.error = str(exc)[:500] await db.commit() @router.get("/collected") async def is_collected(platform: str, itemId: str, db: AsyncSession = Depends(get_db)): rows = (await db.execute( select(Product.id).where( Product.source_platform == platform, Product.source_item_id == itemId, ) )).scalars().all() return {"collected": len(rows) > 0, "count": len(rows)}