"""套图业务逻辑(从 api/suite.py 下沉):文本素材转换 / 模型校验 / 生成图回写商品素材。 api 层只留参数校验与调用;本模块可独立测试。 """ from __future__ import annotations import uuid from schemas.suite import TextMaterial from services.image_assets import get_image_asset_service from services.tasks import TaskImage def texts_to_raw(texts: list[TextMaterial]) -> dict: """前端组装的文本素材 → prompt 上下文用的 raw dict(后写的覆盖先写的)。""" raw: dict = {} for t in texts: if t.kind == "title" and t.content: raw["title"] = t.content elif t.kind == "title_ru" and t.content: raw["title_ru"] = t.content elif t.kind == "price" and t.content: raw["price"] = t.content elif t.kind == "brand" and t.content: raw["brand"] = t.content elif t.kind == "params" and t.pairs: 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" and t.content: raw["sellingPoints"] = t.content elif t.kind == "desc" and t.content: raw["desc"] = t.content elif t.kind == "sales" and t.content: raw["sales"] = t.content elif t.kind == "shop" and t.content: raw["shop"] = t.content return raw def validate_model(provider_name: str, model: str | None) -> None: """按 provider 校验模型名白名单,不合法抛 ValueError。""" from schemas.suite import PCORE_MODELS, RIGHTAPI_MODELS, TONGYI_MODELS, api_model m = api_model(model) # 'gpt-image-2@pcore' → 'gpt-image-2' if provider_name == "tongyi" and m and m not in TONGYI_MODELS: raise ValueError(f"不支持的模型: {m}(tongyi 支持: {TONGYI_MODELS})") if provider_name == "rightapi" and m and m not in RIGHTAPI_MODELS: raise ValueError(f"不支持的模型: {m}(rightapi 支持: {RIGHTAPI_MODELS})") if provider_name == "pcore" and m and m not in PCORE_MODELS: raise ValueError(f"不支持的模型: {m}(pcore 支持: {PCORE_MODELS})") async def append_generated_asset( product_id: str, image: TaskImage, after_asset_id: str | None = None, ) -> str | None: """把一张生成完成的图追加为 product_assets(generated),并累加 asset_counts。返回 asset_id。 after_asset_id:插入锚点——新素材排在锚点之后(其余素材顺次后移),方便与原图对比; 缺省/锚点无效时追加到组尾。作为 run_suite 的逐张回调使用时签名须为 (image), 调用方用 partial 绑定 product_id。 """ from sqlalchemy import func, select from db import get_session_factory from models import Product, ProductAsset pid = uuid.UUID(product_id) async with get_session_factory()() as db: product = await db.get(Product, pid) if product is None: raise RuntimeError("商品已删除,生成结果不再回写") sort_order: int | None = None if after_asset_id: try: anchor = await db.get(ProductAsset, UUID(after_asset_id)) except ValueError: anchor = None if anchor is not None and anchor.product_id == pid and anchor.group_key == "generated": # 锚点之后的素材顺次后移,腾出插入位 followers = ( await db.scalars( select(ProductAsset).where( ProductAsset.product_id == pid, ProductAsset.group_key == "generated", ProductAsset.sort_order > anchor.sort_order, ) ) ).scalars().all() for follower in followers: follower.sort_order += 1 sort_order = anchor.sort_order + 1 if sort_order is None: sort_order = await db.scalar( select(func.coalesce(func.max(ProductAsset.sort_order), -1)).where( ProductAsset.product_id == pid, ProductAsset.group_key == "generated", ) ) sort_order = (sort_order or 0) + 1 asset = ProductAsset( product_id=pid, group_key="generated", variant_name=None, sort_order=sort_order, type="img", source_url="", status="uploaded", ) db.add(asset) await db.flush() image_assets = get_image_asset_service() original_url = image.source_url or image.url await image_assets.adopt_generated_versions( asset, original_url=original_url, current_url=image.url, watermark_config=image.watermark_config, ) counts = dict(product.asset_counts or {}) counts["generated"] = int(counts.get("generated") or 0) + 1 product.asset_counts = counts await db.commit() # task 的 suites/ 临时对象已迁入正式素材路径,提交成功后立即回收。 try: await image_assets.delete_temporary_urls([image.source_url, image.url]) except Exception: # noqa: BLE001 # 当前素材已可用;临时对象残留可由删除商品时的前缀清理兜底。 pass image.source_url = asset.original_url or "" image.url = asset.stored_url or "" return str(asset.id)