"""采集入库:插件上传文本 + 图片 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) # 多批上报的最后一批可能全被去重;仍要扫描此前批次留下的 pending 素材。 if req.process_assets: 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)