a77ec26a02
- 删除 shops/publish/categories/ozon 相关路由、模型、schema 与客户端服务(V2.1 决策放弃 API 直传,改人工上传)
- materials 新增 DELETE /assets/{id}:删除素材记录与本地文件,并同步修正 asset_counts
- 试算页支持单张素材删除,生图弹窗交互微调
- 新增 docs/v2.1/HANDOFF.md 工作交接说明,README 补充索引
117 lines
4.5 KiB
Python
117 lines
4.5 KiB
Python
"""套图业务逻辑(从 api/suite.py 下沉):文本素材转换 / 模型校验 / 生成图回写商品素材。
|
||
|
||
api 层只留参数校验与调用;本模块可独立测试。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
|
||
from schemas.suite import TextMaterial
|
||
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 == "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 RIGHTAPI_MODELS, TONGYI_MODELS
|
||
|
||
if provider_name == "tongyi" and model and model not in TONGYI_MODELS:
|
||
raise ValueError(f"不支持的模型: {model}(tongyi 支持: {TONGYI_MODELS})")
|
||
if provider_name == "rightapi" and model and model not in RIGHTAPI_MODELS:
|
||
raise ValueError(f"不支持的模型: {model}(rightapi 支持: {RIGHTAPI_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:
|
||
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="",
|
||
stored_url=image.url,
|
||
status="uploaded",
|
||
)
|
||
db.add(asset)
|
||
await db.flush()
|
||
|
||
product = await db.get(Product, pid)
|
||
if product is not None:
|
||
counts = dict(product.asset_counts or {})
|
||
counts["generated"] = int(counts.get("generated") or 0) + 1
|
||
product.asset_counts = counts
|
||
await db.commit()
|
||
return str(asset.id)
|