feat: 初始化项目,并且接近完成 ozon 部分
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
"""采集入库:插件上传文本 + 图片 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)}
|
||||
@@ -0,0 +1,126 @@
|
||||
"""无状态套图生成:请求自带采集数据,不落商品库。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
|
||||
from config import get_settings
|
||||
from db import get_db
|
||||
from models import Suite
|
||||
from schemas import (
|
||||
GenerateRequest, PLATFORM_SPECS, SUPPORTED_TYPES, SuiteCreateResponse, TextMaterial,
|
||||
PlanRequest, PlanResponse, PlanItemOut,
|
||||
)
|
||||
from services.generator import run_suite
|
||||
from services.planner import generate_plan
|
||||
from services.prompt import type_name
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["generate"])
|
||||
|
||||
|
||||
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
|
||||
return raw
|
||||
|
||||
|
||||
@router.post("/generate", response_model=SuiteCreateResponse)
|
||||
async def generate_suite(
|
||||
req: GenerateRequest,
|
||||
background: BackgroundTasks,
|
||||
db=Depends(get_db),
|
||||
) -> SuiteCreateResponse:
|
||||
if not req.images:
|
||||
raise HTTPException(status_code=400, detail="未勾选任何图片,无法生成")
|
||||
|
||||
# 生成任务列表:方案优先;无方案时按 types(空则默认四种)
|
||||
if req.plan:
|
||||
jobs: list[dict] = []
|
||||
for item in req.plan:
|
||||
if item.count <= 0:
|
||||
continue
|
||||
if item.kind not in SUPPORTED_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"方案项「{item.title}」的图类型不支持: {item.kind}")
|
||||
# 同一项多张 → 展开为多任务,第二张起在标题上加序号
|
||||
for n in range(item.count):
|
||||
jobs.append({
|
||||
"kind": item.kind,
|
||||
"title": item.title if item.count == 1 else f"{item.title}{n + 1}",
|
||||
"detail": item.detail,
|
||||
"prompt_hint": item.prompt_hint,
|
||||
"variant_name": item.variant_name,
|
||||
})
|
||||
if not jobs:
|
||||
raise HTTPException(status_code=400, detail="方案中所有项的数量都是 0")
|
||||
types = list(dict.fromkeys(j["kind"] for j in jobs))
|
||||
else:
|
||||
types = req.types or ["white_bg", "key_features", "lifestyle", "multi_scene"]
|
||||
bad = [t for t in types if t not in SUPPORTED_TYPES]
|
||||
if bad:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的图类型: {bad}")
|
||||
jobs = [{"kind": t, "title": type_name(t), "detail": "", "prompt_hint": "", "variant_name": None} for t in types]
|
||||
|
||||
if req.platform not in PLATFORM_SPECS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的目标平台: {req.platform}(ozon | wb | cn)")
|
||||
spec = PLATFORM_SPECS[req.platform]
|
||||
|
||||
settings = get_settings()
|
||||
suite = Suite(
|
||||
product_id=None,
|
||||
style_set=req.style_set,
|
||||
platform=req.platform,
|
||||
lang=spec["lang"],
|
||||
ratio=spec["ratio"],
|
||||
types=types,
|
||||
plan=jobs,
|
||||
provider=req.provider or settings.image_provider,
|
||||
context=texts_to_raw(req.texts),
|
||||
# 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配)
|
||||
ref_images=[
|
||||
{
|
||||
"url": i.url,
|
||||
"group_key": i.group_key,
|
||||
"variant_name": i.variant_name,
|
||||
}
|
||||
for i in sorted(req.images, key=lambda x: 0 if x.group_key == "main" else 1)
|
||||
],
|
||||
)
|
||||
db.add(suite)
|
||||
await db.commit()
|
||||
await db.refresh(suite)
|
||||
|
||||
background.add_task(run_suite, str(suite.id))
|
||||
return SuiteCreateResponse(suite_id=str(suite.id))
|
||||
|
||||
|
||||
@router.post("/plan", response_model=PlanResponse)
|
||||
async def plan_suite(req: PlanRequest) -> PlanResponse:
|
||||
"""DeepSeek 根据商品信息生成出图方案。"""
|
||||
product_info = texts_to_raw(req.texts)
|
||||
if not product_info.get("title"):
|
||||
raise HTTPException(status_code=400, detail="缺少商品标题,无法规划")
|
||||
try:
|
||||
result = await generate_plan(product_info, req.sku_variants, req.image_stats, req.platform)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"规划失败: {exc}") from exc
|
||||
return PlanResponse(
|
||||
summary=result["summary"],
|
||||
items=[PlanItemOut(**i) for i in result["items"]],
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""商品查询 API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db import get_db
|
||||
from models import Product, ProductAsset
|
||||
from schemas import AssetOut, ProductListOut, ProductOut
|
||||
from services import storage
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["products"])
|
||||
|
||||
|
||||
def _asset_out(a: ProductAsset) -> AssetOut:
|
||||
return AssetOut(
|
||||
id=str(a.id),
|
||||
group_key=a.group_key,
|
||||
variant_name=a.variant_name,
|
||||
type=a.type,
|
||||
source_url=a.source_url,
|
||||
url=a.stored_url,
|
||||
status=a.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/products", response_model=ProductListOut)
|
||||
async def list_products(
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
cond = []
|
||||
if q:
|
||||
cond.append(Product.name.contains(q))
|
||||
total = (await db.scalar(select(func.count()).select_from(Product).where(*cond))) or 0
|
||||
rows = (await db.scalars(
|
||||
select(Product).where(*cond).order_by(Product.created_at.desc())
|
||||
.offset((page - 1) * page_size).limit(page_size)
|
||||
)).all()
|
||||
return ProductListOut(total=total, items=[
|
||||
ProductOut(
|
||||
id=str(p.id), stage=p.stage, source_platform=p.source_platform,
|
||||
source_item_id=p.source_item_id, source_url=p.source_url,
|
||||
name=p.name, description=p.description, price=p.price,
|
||||
asset_counts=p.asset_counts,
|
||||
created_at=p.created_at.isoformat() if p.created_at else None,
|
||||
) for p in rows
|
||||
])
|
||||
|
||||
|
||||
@router.get("/products/{product_id}", response_model=ProductOut)
|
||||
async def get_product(product_id: str, db: AsyncSession = Depends(get_db)):
|
||||
p = await db.get(Product, UUID(product_id))
|
||||
if p is None:
|
||||
raise HTTPException(status_code=404, detail="商品不存在")
|
||||
assets = (await db.scalars(
|
||||
select(ProductAsset).where(ProductAsset.product_id == p.id)
|
||||
.order_by(ProductAsset.sort_order)
|
||||
)).all()
|
||||
return ProductOut(
|
||||
id=str(p.id), stage=p.stage, source_platform=p.source_platform,
|
||||
source_item_id=p.source_item_id, source_url=p.source_url,
|
||||
name=p.name, description=p.description, price=p.price,
|
||||
asset_counts=p.asset_counts, assets=[_asset_out(a) for a in assets],
|
||||
created_at=p.created_at.isoformat() if p.created_at else None,
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""图片代理:绕过源站防盗链,供前端 <img> 预览与生图参考使用。
|
||||
|
||||
参考 laowang.putumiao.shop 的 /api/proxy-image?url=... 形式。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
|
||||
from services import storage
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["proxy"])
|
||||
|
||||
# 域名片段 → 防盗链所需 Referer
|
||||
_REFERER_BY_DOMAIN: list[tuple[str, str]] = [
|
||||
("alicdn.com", "https://www.taobao.com"),
|
||||
("taobao.com", "https://www.taobao.com"),
|
||||
("tmall.com", "https://www.tmall.com"),
|
||||
("1688.com", "https://www.1688.com"),
|
||||
("ozon.ru", "https://www.ozon.ru"),
|
||||
("ozon.kz", "https://www.ozon.ru"),
|
||||
("ozon.by", "https://www.ozon.ru"),
|
||||
("ozonusercontent.com", "https://www.ozon.ru"),
|
||||
]
|
||||
|
||||
|
||||
def guess_referer(url: str) -> str | None:
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
for frag, referer in _REFERER_BY_DOMAIN:
|
||||
if frag in host:
|
||||
return referer
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/proxy-image")
|
||||
async def proxy_image(url: str = Query(..., description="源站图片 URL")):
|
||||
scheme = urlparse(url).scheme
|
||||
if scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=400, detail="仅支持 http/https URL")
|
||||
try:
|
||||
data, ctype = await storage.download_bytes(url, referer=guess_referer(url))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"图片拉取失败: {exc}") from exc
|
||||
if not ctype.startswith("image/"):
|
||||
ctype = "image/jpeg"
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=ctype,
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""套图生成 API:创建任务 / 查询状态 / 导出 ZIP。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import get_settings
|
||||
from db import get_db
|
||||
from models import Product, ProductAsset, Suite, SuiteImage, STATUS_OK
|
||||
from schemas import PLATFORM_SPECS, SUPPORTED_TYPES, SuiteCreateRequest, SuiteCreateResponse, SuiteImageOut, SuiteOut
|
||||
from services import storage
|
||||
from services.generator import run_suite
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["suites"])
|
||||
|
||||
|
||||
async def _suite_out(db: AsyncSession, suite: Suite) -> SuiteOut:
|
||||
images = (await db.scalars(
|
||||
select(SuiteImage).where(SuiteImage.suite_id == suite.id)
|
||||
.order_by(SuiteImage.created_at)
|
||||
)).all()
|
||||
return SuiteOut(
|
||||
id=str(suite.id),
|
||||
product_id=str(suite.product_id),
|
||||
status=suite.status,
|
||||
style_set=suite.style_set,
|
||||
platform=suite.platform,
|
||||
lang=suite.lang,
|
||||
ratio=suite.ratio,
|
||||
types=list(suite.types or []),
|
||||
provider=suite.provider,
|
||||
images=[
|
||||
SuiteImageOut(
|
||||
type_id=i.type_id, name=i.name, url=i.stored_url or "",
|
||||
status=i.status, error=i.error,
|
||||
) for i in images
|
||||
],
|
||||
error=suite.error,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/suites", response_model=SuiteCreateResponse)
|
||||
async def create_suite(
|
||||
product_id: str,
|
||||
req: SuiteCreateRequest,
|
||||
background: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
product = await db.get(Product, UUID(product_id))
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="商品不存在")
|
||||
|
||||
# 主图组至少一张图(不要求转存完成:生图可直接用源站 URL 代理解析)
|
||||
ok_assets = (await db.scalars(
|
||||
select(ProductAsset.id).where(
|
||||
ProductAsset.product_id == product.id,
|
||||
ProductAsset.group_key == "main",
|
||||
ProductAsset.type == "img",
|
||||
)
|
||||
)).all()
|
||||
if not ok_assets:
|
||||
raise HTTPException(status_code=400, detail="商品没有主图,无法生成")
|
||||
|
||||
bad = [t for t in req.types if t not in SUPPORTED_TYPES]
|
||||
if bad:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的图类型: {bad}")
|
||||
if req.platform not in PLATFORM_SPECS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的目标平台: {req.platform}(ozon | wb | cn)")
|
||||
spec = PLATFORM_SPECS[req.platform]
|
||||
|
||||
settings = get_settings()
|
||||
suite = Suite(
|
||||
product_id=product.id,
|
||||
style_set=req.style_set,
|
||||
platform=req.platform,
|
||||
lang=spec["lang"],
|
||||
ratio=spec["ratio"],
|
||||
types=req.types,
|
||||
provider=req.provider or settings.image_provider,
|
||||
)
|
||||
db.add(suite)
|
||||
await db.commit()
|
||||
await db.refresh(suite)
|
||||
|
||||
background.add_task(run_suite, str(suite.id))
|
||||
return SuiteCreateResponse(suite_id=str(suite.id))
|
||||
|
||||
|
||||
@router.get("/suites/{suite_id}", response_model=SuiteOut)
|
||||
async def get_suite(suite_id: str, db: AsyncSession = Depends(get_db)):
|
||||
suite = await db.get(Suite, UUID(suite_id))
|
||||
if suite is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return await _suite_out(db, suite)
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/suites")
|
||||
async def list_suites(product_id: str, db: AsyncSession = Depends(get_db)):
|
||||
suites = (await db.scalars(
|
||||
select(Suite).where(Suite.product_id == UUID(product_id))
|
||||
.order_by(Suite.created_at.desc())
|
||||
)).all()
|
||||
return [await _suite_out(db, s) for s in suites]
|
||||
|
||||
|
||||
@router.get("/suites/{suite_id}/zip")
|
||||
async def download_suite_zip(suite_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""把任务内所有成功图打包成 ZIP(中文文件名)。"""
|
||||
suite = await db.get(Suite, UUID(suite_id))
|
||||
if suite is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
images = (await db.scalars(
|
||||
select(SuiteImage).where(
|
||||
SuiteImage.suite_id == suite.id, SuiteImage.status == STATUS_OK,
|
||||
).order_by(SuiteImage.created_at)
|
||||
)).all()
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
seen: set[str] = set()
|
||||
for i, img in enumerate(images):
|
||||
path = storage.local_path(img.stored_url or "")
|
||||
if path is None:
|
||||
continue
|
||||
filename = img.name or img.type_id
|
||||
if filename in seen: # 同类型多张时加序号防覆盖
|
||||
filename = f"{filename}-{i + 1}"
|
||||
seen.add(filename)
|
||||
zf.write(path, f"{filename}{path.suffix or '.jpg'}")
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="suite-{suite_id}.zip"'},
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""配置:仓库根 .env 或环境变量。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# 仓库根(server/ 的上一级)
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(ROOT / ".env"),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ── 服务 ──
|
||||
# 不要用 5000/7000:macOS 隔空播放接收器占用(IPv6 localhost 会被截走)
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 3300
|
||||
app_base_url: str = "http://127.0.0.1:3300"
|
||||
|
||||
# ── 存储 ──
|
||||
data_dir: str = str(ROOT / "data")
|
||||
|
||||
# ── 图像生成 provider:doubao(火山方舟 Seedream)| tongyi(阿里 DashScope)──
|
||||
image_provider: str = "doubao"
|
||||
request_timeout: int = 300 # 单张生图请求超时(秒)
|
||||
poll_max_wait: int = 600 # 异步任务轮询上限(秒)
|
||||
|
||||
# 豆包 / 火山方舟
|
||||
ark_api_key: str = ""
|
||||
ark_base_url: str = "https://ark.cn-beijing.volces.com/api/v3/images/generations"
|
||||
ark_image_model: str = "doubao-seedream-4-5-251128"
|
||||
|
||||
# 通义 / DashScope
|
||||
dashscope_api_key: str = ""
|
||||
dashscope_base_url: str = "" # 留空按模型自动选择万象异步/千问同步端点
|
||||
dashscope_model: str = "wan2.7-image-pro"
|
||||
|
||||
# DeepSeek(出图方案规划器)
|
||||
deepseek_api_key: str = ""
|
||||
deepseek_base_url: str = "https://api.deepseek.com/v1"
|
||||
deepseek_model: str = "deepseek-v4-flash"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""数据库:SQLite(aiosqlite)+ SQLAlchemy async。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
_engine = None
|
||||
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
global _engine, _session_factory
|
||||
if _engine is None:
|
||||
settings = get_settings()
|
||||
db_path = f"{settings.data_dir}/app.db"
|
||||
_engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", echo=False)
|
||||
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
get_engine()
|
||||
assert _session_factory is not None
|
||||
return _session_factory
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with get_session_factory()() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""启动时建表(MVP 不引 Alembic,模型变更删库重建即可)。"""
|
||||
import models # noqa: F401 确保模型注册
|
||||
|
||||
engine = get_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""电商套图工作台 - FastAPI 入口。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from api import collection, generate, products, proxy, suites
|
||||
from config import get_settings
|
||||
from db import init_db
|
||||
from services.storage import media_root
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="电商套图工作台", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 插件 background 无 CORS 限制,这里兜底
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(collection.router)
|
||||
app.include_router(products.router)
|
||||
app.include_router(suites.router)
|
||||
app.include_router(generate.router)
|
||||
app.include_router(proxy.router)
|
||||
|
||||
# 静态托管生成的图片/转存素材
|
||||
app.mount("/media", StaticFiles(directory=str(media_root())), name="media")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
settings = get_settings()
|
||||
return {
|
||||
"ok": True,
|
||||
"provider": settings.image_provider,
|
||||
"ark_configured": bool(settings.ark_api_key),
|
||||
"dashscope_configured": bool(settings.dashscope_api_key),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
settings = get_settings()
|
||||
uvicorn.run("main:app", host=settings.host, port=settings.port, reload=False)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""数据模型:Product(商品)/ ProductAsset(采集素材)/ Suite(套图任务)/ SuiteImage(生成图)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, JSON, String, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from db import Base
|
||||
|
||||
# 产品阶段
|
||||
STAGE_COLLECTED = "collected"
|
||||
STAGE_GENERATED = "generated"
|
||||
|
||||
# 素材/生成图状态
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_DOWNLOADING = "downloading"
|
||||
STATUS_OK = "ok"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
# 套图任务状态
|
||||
SUITE_PENDING = "pending"
|
||||
SUITE_RUNNING = "running"
|
||||
SUITE_DONE = "done"
|
||||
SUITE_PARTIAL = "partial"
|
||||
SUITE_FAILED = "failed"
|
||||
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
stage: Mapped[str] = mapped_column(String(16), default=STAGE_COLLECTED, index=True)
|
||||
|
||||
# 采集溯源
|
||||
source_platform: Mapped[str | None] = mapped_column(String(16), nullable=True) # ozon | 1688 | taobao
|
||||
source_item_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
name: Mapped[str] = mapped_column(Text, default="")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# 采集原文:{title, price, brand, params: [...], sellingPoints, desc, texts: [...]}
|
||||
raw: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
asset_counts: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), index=True
|
||||
)
|
||||
|
||||
|
||||
class ProductAsset(Base):
|
||||
"""采集素材(源站图片,转存到本地 media)。"""
|
||||
|
||||
__tablename__ = "product_assets"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
product_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
group_key: Mapped[str] = mapped_column(String(16), default="main") # main/sku/detail/video
|
||||
variant_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
type: Mapped[str] = mapped_column(String(8), default="img") # img / video
|
||||
source_url: Mapped[str] = mapped_column(Text, default="")
|
||||
stored_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 本地 media key 或公网 URL
|
||||
status: Mapped[str] = mapped_column(String(16), default=STATUS_PENDING, index=True)
|
||||
dedupe_key: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class Suite(Base):
|
||||
"""一次套图生成任务(无状态:直接携带采集数据,不依赖商品库)。"""
|
||||
|
||||
__tablename__ = "suites"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
# 兼容旧的商品挂载路径;工具化流程为空
|
||||
product_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(16), default=SUITE_PENDING, index=True)
|
||||
style_set: Mapped[int] = mapped_column(Integer, default=1) # 风格模板 1-5
|
||||
platform: Mapped[str] = mapped_column(String(8), default="cn") # 目标平台 ozon | wb | cn
|
||||
lang: Mapped[str] = mapped_column(String(4), default="zh") # ru / zh(由平台推导)
|
||||
ratio: Mapped[str] = mapped_column(String(8), default="1:1") # 图片比例(由平台推导)
|
||||
types: Mapped[list | None] = mapped_column(JSON, nullable=True) # 图类型 id 列表(旧)
|
||||
plan: Mapped[list | None] = mapped_column(JSON, nullable=True) # 出图方案(展开后的逐张任务)
|
||||
provider: Mapped[str] = mapped_column(String(16), default="doubao")
|
||||
# 工具化流程:请求自带的数据(生图上下文 + 参考图 URL 列表)
|
||||
context: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
ref_images: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class SuiteImage(Base):
|
||||
"""任务里单张生成图。"""
|
||||
|
||||
__tablename__ = "suite_images"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
suite_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("suites.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
type_id: Mapped[str] = mapped_column(String(32)) # white_bg / key_features / ...
|
||||
name: Mapped[str] = mapped_column(String(64), default="") # 中文名(文件名)
|
||||
stored_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default=STATUS_PENDING)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.29
|
||||
sqlalchemy[asyncio]>=2.0
|
||||
aiosqlite>=0.20
|
||||
pydantic>=2.6
|
||||
pydantic-settings>=2.2
|
||||
httpx>=0.27
|
||||
python-multipart>=0.0.9
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Pydantic 契约(插件 ↔ 服务端)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
SUPPORTED_TYPES = [
|
||||
"white_bg", "key_features", "selling_pt", "material",
|
||||
"lifestyle", "multi_scene", "ecommerce_detail",
|
||||
"size_chart", "sku_collection", "custom",
|
||||
]
|
||||
|
||||
|
||||
# ── 采集上传 ──
|
||||
|
||||
class SourceInfo(BaseModel):
|
||||
platform: str = Field(..., description="ozon | 1688 | taobao")
|
||||
itemId: str | None = None
|
||||
url: str = ""
|
||||
collectedAt: int | None = None # epoch 毫秒
|
||||
|
||||
|
||||
class TextMaterial(BaseModel):
|
||||
kind: str = Field(..., description="title | params | selling_point | desc | price | brand")
|
||||
content: str = ""
|
||||
pairs: list[dict] | None = None # [{key, value}]
|
||||
|
||||
|
||||
class ImageMaterial(BaseModel):
|
||||
groupKey: str = Field(..., description="main | sku | detail | video")
|
||||
groupName: str = ""
|
||||
variantName: str | None = None
|
||||
url: str = Field(..., description="源站原图 URL")
|
||||
index: int = 0
|
||||
type: str = "img"
|
||||
dedupeKey: str | None = None
|
||||
|
||||
|
||||
class MaterialsRequest(BaseModel):
|
||||
product_id: str | None = Field(default=None, description="传了=追加到已有商品")
|
||||
source: SourceInfo
|
||||
texts: list[TextMaterial] = Field(default_factory=list)
|
||||
images: list[ImageMaterial] = Field(default_factory=list)
|
||||
refererOrigin: str | None = None
|
||||
|
||||
|
||||
class MaterialsResponse(BaseModel):
|
||||
product_id: str
|
||||
assets_queued: int
|
||||
assets_skipped: int = 0
|
||||
|
||||
|
||||
# ── 套图生成 ──
|
||||
|
||||
# 目标平台 → 文案语言 + 图片比例(平台决定规格,不再单独选语言)
|
||||
PLATFORM_SPECS: dict[str, dict] = {
|
||||
"ozon": {"lang": "ru", "ratio": "3:4", "label": "Ozon"},
|
||||
"wb": {"lang": "ru", "ratio": "3:4", "label": "Wildberries"},
|
||||
"cn": {"lang": "zh", "ratio": "1:1", "label": "中文(国内平台)"},
|
||||
}
|
||||
|
||||
|
||||
class SuiteCreateRequest(BaseModel):
|
||||
style_set: int = Field(default=1, ge=1, le=5, description="风格模板 1-5")
|
||||
types: list[str] = Field(default_factory=lambda: ["white_bg", "key_features", "lifestyle", "multi_scene"])
|
||||
platform: str = Field(default="cn", description="目标平台:ozon | wb | cn")
|
||||
provider: str | None = Field(default=None, description="覆盖默认 provider(doubao | tongyi)")
|
||||
|
||||
|
||||
# ── 无状态套图生成(工具流程:请求自带采集数据)──
|
||||
|
||||
class GenerateImageItem(BaseModel):
|
||||
url: str = Field(..., description="勾选的图片 URL(源站原图)")
|
||||
group_key: str = Field(default="main", description="main | sku | detail")
|
||||
variant_name: str | None = Field(default=None, description="SKU 规格名(方案绑定用)")
|
||||
|
||||
|
||||
class PlanItem(BaseModel):
|
||||
"""出图方案项:一类图 × 数量,可绑定 SKU 规格。"""
|
||||
kind: str = Field(default="custom", description="图类型(SUPPORTED_TYPES 之一)")
|
||||
title: str = Field(..., description="方案标题,如「主图·粉色」")
|
||||
detail: str = Field(default="", description="这张图展示什么(中文)")
|
||||
prompt_hint: str = Field(default="", description="构图提示(英文,进生图 prompt)")
|
||||
count: int = Field(default=1, ge=0, le=5)
|
||||
variant_name: str | None = Field(default=None, description="绑定的 SKU 规格名")
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
texts: list[TextMaterial] = Field(default_factory=list, description="采集的文本素材")
|
||||
images: list[GenerateImageItem] = Field(default_factory=list, description="勾选的参考图")
|
||||
style_set: int = Field(default=1, ge=1, le=5)
|
||||
types: list[str] = Field(default_factory=list, description="旧参数:无方案时按类型生成")
|
||||
plan: list[PlanItem] | None = Field(default=None, description="出图方案(优先于 types)")
|
||||
platform: str = Field(default="cn", description="目标平台:ozon | wb | cn")
|
||||
provider: str | None = Field(default=None, description="覆盖默认 provider(doubao | tongyi)")
|
||||
|
||||
|
||||
# ── 出图方案规划(DeepSeek)──
|
||||
|
||||
class PlanRequest(BaseModel):
|
||||
texts: list[TextMaterial] = Field(default_factory=list)
|
||||
sku_variants: list[str] = Field(default_factory=list, description="带图的 SKU 规格名")
|
||||
image_stats: dict = Field(default_factory=dict, description="分组图片数量统计")
|
||||
platform: str = Field(default="cn")
|
||||
|
||||
|
||||
class PlanItemOut(BaseModel):
|
||||
kind: str
|
||||
title: str
|
||||
detail: str = ""
|
||||
prompt_hint: str = ""
|
||||
count: int = 1
|
||||
variant_name: str | None = None
|
||||
|
||||
|
||||
class PlanResponse(BaseModel):
|
||||
summary: str = ""
|
||||
items: list[PlanItemOut]
|
||||
|
||||
|
||||
class SuiteCreateResponse(BaseModel):
|
||||
suite_id: str
|
||||
|
||||
|
||||
class SuiteImageOut(BaseModel):
|
||||
type_id: str
|
||||
name: str
|
||||
url: str
|
||||
status: str
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class SuiteOut(BaseModel):
|
||||
id: str
|
||||
product_id: str
|
||||
status: str
|
||||
style_set: int
|
||||
platform: str
|
||||
lang: str
|
||||
ratio: str
|
||||
types: list[str]
|
||||
provider: str
|
||||
images: list[SuiteImageOut]
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# ── 商品 ──
|
||||
|
||||
class AssetOut(BaseModel):
|
||||
id: str
|
||||
group_key: str
|
||||
variant_name: str | None = None
|
||||
type: str
|
||||
source_url: str
|
||||
url: str | None = None
|
||||
status: str
|
||||
|
||||
|
||||
class ProductOut(BaseModel):
|
||||
id: str
|
||||
stage: str
|
||||
source_platform: str | None = None
|
||||
source_item_id: str | None = None
|
||||
source_url: str | None = None
|
||||
name: str
|
||||
description: str
|
||||
price: float | None = None
|
||||
asset_counts: dict | None = None
|
||||
assets: list[AssetOut] = Field(default_factory=list)
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class ProductListOut(BaseModel):
|
||||
total: int
|
||||
items: list[ProductOut]
|
||||
@@ -0,0 +1,316 @@
|
||||
"""套图生成服务:图像 provider(豆包 Seedream / 通义万相)+ 任务执行器。
|
||||
|
||||
Provider 调用方式移植自 ecommerce-image-suite/scripts/generate.py:
|
||||
- doubao:火山方舟 images/generations,同步返回 URL;参考图走 image 字段(data URI)
|
||||
- tongyi:wan* 万象模型走异步任务轮询;qwen* 走同步 multimodal-generation
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from config import get_settings
|
||||
from db import get_session_factory
|
||||
from models import Product, ProductAsset, Suite, SuiteImage, SUITE_RUNNING, SUITE_DONE, SUITE_PARTIAL, SUITE_FAILED, STATUS_OK, STATUS_FAILED
|
||||
from services import storage
|
||||
from services.prompt import build_prompt, build_context, type_name
|
||||
|
||||
log = logging.getLogger("suite.generator")
|
||||
|
||||
# 参考图选择:material 用第 2 张(背面/细节),其余用第 1 张(正面)
|
||||
TYPE_REF_INDEX = {
|
||||
"material": 1,
|
||||
}
|
||||
DEFAULT_REF_COUNT = 2 # 每次生图最多带的参考图数(正面 1 张 + 背面/细节 1 张)
|
||||
|
||||
|
||||
def _image_size(provider: str, ratio: str, is_wan: bool = True) -> str:
|
||||
"""平台比例 → provider 尺寸参数。3:4 竖版(Ozon/WB),1:1 方图(国内)。"""
|
||||
if provider == "doubao":
|
||||
return "1536x2048" if ratio == "3:4" else "2048x2048"
|
||||
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
|
||||
if ratio == "3:4":
|
||||
return "1536*2048" if is_wan else "768*1024"
|
||||
return "2048*2048" if is_wan else "1024*1024"
|
||||
|
||||
_DOUBAO_ANTI_AI = (
|
||||
"authentic real-world photography, natural imperfections, genuine texture, "
|
||||
"no synthetic look, no CGI quality, no heavy post-processing"
|
||||
)
|
||||
|
||||
DEFAULT_NEGATIVE_PROMPT = (
|
||||
"AI-generated look, artificial, CGI quality, 3D render, synthetic texture, "
|
||||
"plastic skin, mannequin-like, too perfect, oversaturated, HDR, heavy vignette, "
|
||||
"low resolution, blurry, deformed, bad anatomy, overexposed, underexposed, grainy, "
|
||||
"watermark, text distortion, bad typography, overlapping text, cheap look, cartoon"
|
||||
)
|
||||
|
||||
|
||||
# ── 参考图解析 ────────────────────────────────────────────────────────────
|
||||
|
||||
def _bytes_to_data_uri(data: bytes, mime: str) -> str:
|
||||
return f"data:{mime};base64,{base64.b64encode(data).decode()}"
|
||||
|
||||
|
||||
async def _resolve_ref(url: str) -> str:
|
||||
"""参考图 URL → data URI。本地 media 文件直读磁盘;远程 URL 带 Referer 下载。
|
||||
|
||||
生图 API 的服务器无法访问 127.0.0.1,代理 URL 也不能直接透传,
|
||||
所以统一在本地解析成 base64 data URI 再进请求体。
|
||||
"""
|
||||
if url.startswith("data:"):
|
||||
return url
|
||||
path = storage.local_path(url)
|
||||
if path is not None:
|
||||
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
||||
return _bytes_to_data_uri(path.read_bytes(), mime)
|
||||
if url.startswith(("http://", "https://")):
|
||||
from api.proxy import guess_referer
|
||||
data, ctype = await storage.download_bytes(url, referer=guess_referer(url))
|
||||
if not ctype.startswith("image/"):
|
||||
ctype = "image/jpeg"
|
||||
return _bytes_to_data_uri(data, ctype)
|
||||
raise FileNotFoundError(f"无法解析参考图: {url}")
|
||||
|
||||
|
||||
# ── Provider:豆包 Seedream(火山方舟)────────────────────────────────────
|
||||
|
||||
async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048") -> bytes:
|
||||
s = get_settings()
|
||||
if not s.ark_api_key:
|
||||
raise RuntimeError("未配置 ARK_API_KEY(.env)")
|
||||
body = {
|
||||
"model": s.ark_image_model,
|
||||
"prompt": prompt.rstrip(". ") + ". " + _DOUBAO_ANTI_AI,
|
||||
"size": size,
|
||||
"response_format": "url",
|
||||
"watermark": False,
|
||||
"n": 1,
|
||||
}
|
||||
if ref_images:
|
||||
body["image"] = [await _resolve_ref(u) for u in ref_images]
|
||||
async with httpx.AsyncClient(timeout=s.request_timeout, verify=False) as client:
|
||||
resp = await client.post(
|
||||
s.ark_base_url,
|
||||
headers={"Authorization": f"Bearer {s.ark_api_key}", "Content-Type": "application/json"},
|
||||
json=body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
img_url = resp.json()["data"][0]["url"]
|
||||
dl = await client.get(img_url, timeout=s.request_timeout)
|
||||
dl.raise_for_status()
|
||||
return dl.content
|
||||
|
||||
|
||||
# ── Provider:通义万相 / 千问(DashScope)────────────────────────────────
|
||||
|
||||
def _is_wan_model(model: str) -> bool:
|
||||
return model.lower().startswith("wan")
|
||||
|
||||
|
||||
async def _tongyi_poll_task(client: httpx.AsyncClient, key: str, task_id: str, max_wait: int) -> str:
|
||||
poll_url = "https://dashscope.aliyuncs.com/api/v1/tasks/" + task_id
|
||||
elapsed, interval = 0, 3
|
||||
while elapsed < max_wait:
|
||||
resp = await client.get(poll_url, headers={"Authorization": f"Bearer {key}"}, timeout=30)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
status = result.get("output", {}).get("task_status", "")
|
||||
if status == "SUCCEEDED":
|
||||
choices = result["output"].get("choices", [])
|
||||
if choices:
|
||||
content = choices[0].get("message", {}).get("content", [])
|
||||
if content:
|
||||
return content[0].get("image", "")
|
||||
results = result["output"].get("results", [])
|
||||
if results:
|
||||
return results[0].get("url") or results[0].get("b64_image", "")
|
||||
raise RuntimeError(f"通义任务成功但无结果: {result}")
|
||||
if status in ("FAILED", "UNKNOWN"):
|
||||
raise RuntimeError(f"通义任务失败: {result}")
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
interval = min(interval + 2, 10)
|
||||
raise TimeoutError(f"通义异步任务超时 ({max_wait}s): task_id={task_id}")
|
||||
|
||||
|
||||
async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*2048") -> bytes:
|
||||
s = get_settings()
|
||||
if not s.dashscope_api_key:
|
||||
raise RuntimeError("未配置 DASHSCOPE_API_KEY(.env)")
|
||||
is_wan = _is_wan_model(s.dashscope_model)
|
||||
url = s.dashscope_base_url or (
|
||||
"https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation"
|
||||
if is_wan
|
||||
else "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
||||
)
|
||||
|
||||
content: list[dict] = [{"image": await _resolve_ref(u)} for u in ref_images]
|
||||
content.append({"text": prompt})
|
||||
|
||||
params = {"size": size, "n": 1, "watermark": False}
|
||||
if not is_wan:
|
||||
params["prompt_extend"] = False
|
||||
params["negative_prompt"] = DEFAULT_NEGATIVE_PROMPT[:500]
|
||||
|
||||
headers = {"Authorization": f"Bearer {s.dashscope_api_key}", "Content-Type": "application/json"}
|
||||
if is_wan:
|
||||
headers["X-DashScope-Async"] = "enable"
|
||||
|
||||
body = {"model": s.dashscope_model, "input": {"messages": [{"role": "user", "content": content}]}, "parameters": params}
|
||||
|
||||
async with httpx.AsyncClient(timeout=s.request_timeout, verify=False) as client:
|
||||
resp = await client.post(url, headers=headers, json=body)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if is_wan:
|
||||
task_id = data.get("output", {}).get("task_id", "")
|
||||
if not task_id:
|
||||
raise RuntimeError(f"通义万象未返回 task_id: {data}")
|
||||
img_url = await _tongyi_poll_task(client, s.dashscope_api_key, task_id, s.poll_max_wait)
|
||||
if img_url.startswith("data:") or len(img_url) > 500:
|
||||
return base64.b64decode(img_url.split(",", 1)[-1] if "," in img_url else img_url)
|
||||
dl = await client.get(img_url, timeout=s.request_timeout)
|
||||
dl.raise_for_status()
|
||||
return dl.content
|
||||
img_url = data["output"]["choices"][0]["message"]["content"][0]["image"]
|
||||
dl = await client.get(img_url, timeout=s.request_timeout)
|
||||
dl.raise_for_status()
|
||||
return dl.content
|
||||
|
||||
|
||||
GENERATORS = {"doubao": generate_doubao, "tongyi": generate_tongyi}
|
||||
|
||||
|
||||
# ── 任务执行器 ────────────────────────────────────────────────────────────
|
||||
|
||||
def _order_refs(refs: list[str], type_id: str) -> list[str]:
|
||||
"""参考图槽位选择 + 截断:material 偏好第 2 张,其余用第 1 张。"""
|
||||
preferred = TYPE_REF_INDEX.get(type_id)
|
||||
if preferred is not None and len(refs) > preferred:
|
||||
refs = [refs[preferred]] + [r for i, r in enumerate(refs) if i != preferred]
|
||||
return refs[:DEFAULT_REF_COUNT]
|
||||
|
||||
|
||||
def _refs_for_job(images: list[dict], job: dict) -> list[str]:
|
||||
"""无状态路径:按方案项选参考图。
|
||||
|
||||
优先 variant_name 精确匹配(「主图·粉色」用粉色那张 SKU 图);
|
||||
匹配不到则回退 main 组第一张(再退到任意第一张)。
|
||||
"""
|
||||
variant = job.get("variant_name")
|
||||
if variant:
|
||||
matched = [i["url"] for i in images if i.get("variant_name") == variant]
|
||||
if matched:
|
||||
return matched[:DEFAULT_REF_COUNT]
|
||||
mains = [i["url"] for i in images if i.get("group_key") == "main"]
|
||||
others = [i["url"] for i in images if i.get("group_key") != "main"]
|
||||
pool = mains or others or [i["url"] for i in images]
|
||||
if not pool:
|
||||
raise RuntimeError("任务没有参考图")
|
||||
return _order_refs(pool, job.get("kind", ""))
|
||||
|
||||
|
||||
async def _select_ref_images(db, product_id: UUID, type_id: str) -> list[str]:
|
||||
"""商品路径:主图组前几张。转存完成的用本地文件,未完成的直接用源站 URL。"""
|
||||
assets = (await db.scalars(
|
||||
select(ProductAsset).where(
|
||||
ProductAsset.product_id == product_id,
|
||||
ProductAsset.group_key == "main",
|
||||
ProductAsset.type == "img",
|
||||
).order_by(ProductAsset.sort_order)
|
||||
)).all()
|
||||
refs = [a.stored_url or a.source_url for a in assets if (a.stored_url or a.source_url)]
|
||||
if not refs:
|
||||
raise RuntimeError("商品没有可用参考图(未采集主图)")
|
||||
return _order_refs(refs, type_id)
|
||||
|
||||
|
||||
async def run_suite(suite_id: str) -> None:
|
||||
"""后台执行套图任务:逐张生成 → 落盘 → 记录;单张失败不中断。
|
||||
|
||||
两条路径:
|
||||
- 无状态(product_id 为空):上下文与参考图来自请求自带的 context / ref_images
|
||||
- 商品路径(兼容旧流程):从 product + product_assets 取
|
||||
"""
|
||||
settings = get_settings()
|
||||
async with get_session_factory()() as db:
|
||||
suite = await db.get(Suite, UUID(suite_id))
|
||||
if suite is None:
|
||||
return
|
||||
|
||||
product = None
|
||||
if suite.product_id:
|
||||
product = await db.get(Product, suite.product_id)
|
||||
if product is None:
|
||||
suite.status = SUITE_FAILED
|
||||
suite.error = "商品不存在"
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
suite.status = SUITE_RUNNING
|
||||
await db.commit()
|
||||
|
||||
provider_name = suite.provider or settings.image_provider
|
||||
generator = GENERATORS.get(provider_name)
|
||||
if generator is None:
|
||||
suite.status = SUITE_FAILED
|
||||
suite.error = f"未知 provider: {provider_name}"
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
raw = suite.context if not product else (product.raw or {})
|
||||
ctx = build_context(raw or {}, fallback_name=product.name if product else "")
|
||||
size = _image_size(provider_name, suite.ratio, is_wan=_is_wan_model(settings.dashscope_model))
|
||||
|
||||
# 任务列表:方案(逐张)优先,旧路径按 types
|
||||
if suite.plan:
|
||||
jobs = [dict(j) for j in suite.plan]
|
||||
else:
|
||||
jobs = [
|
||||
{"kind": t, "title": type_name(t), "detail": "", "prompt_hint": "", "variant_name": None}
|
||||
for t in (suite.types or [])
|
||||
]
|
||||
|
||||
ok, failed = 0, 0
|
||||
for job in jobs:
|
||||
type_id = job["kind"]
|
||||
image_row = SuiteImage(
|
||||
suite_id=suite.id,
|
||||
type_id=type_id,
|
||||
name=job.get("title") or type_name(type_id),
|
||||
status=STATUS_FAILED,
|
||||
)
|
||||
db.add(image_row)
|
||||
await db.flush()
|
||||
try:
|
||||
prompt = build_prompt(type_id, ctx, suite.style_set, suite.lang, extra=job)
|
||||
if product:
|
||||
refs = await _select_ref_images(db, product.id, type_id)
|
||||
else:
|
||||
refs = _refs_for_job(list(suite.ref_images or []), job)
|
||||
data = await generator(prompt, refs, size=size)
|
||||
key = storage.write_bytes(data, key_prefix=f"suites/{suite.id}", ext=".jpg")
|
||||
image_row.stored_url = storage.public_url(key)
|
||||
image_row.status = STATUS_OK
|
||||
ok += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("套图 %s 类型 %s 生成失败", suite_id, type_id)
|
||||
image_row.error = str(exc)[:500]
|
||||
failed += 1
|
||||
await db.commit()
|
||||
|
||||
suite.status = SUITE_DONE if failed == 0 else (SUITE_PARTIAL if ok > 0 else SUITE_FAILED)
|
||||
if failed and not ok:
|
||||
suite.error = "全部生成失败,请检查 API Key / 参考图"
|
||||
from datetime import datetime, timezone
|
||||
suite.finished_at = datetime.now(timezone.utc)
|
||||
if product:
|
||||
product.stage = "generated" # 商品路径才有的阶段升级
|
||||
await db.commit()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""出图方案规划器:DeepSeek 根据采集的商品信息生成套图方案。
|
||||
|
||||
方案每项 = 一类图(标题 + 说明 + 生图提示 + 张数 + 可选 SKU 绑定),
|
||||
生成时按方案逐张出图;参考图可按 variant_name 精确绑定到对应 SKU 图。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from config import get_settings
|
||||
|
||||
log = logging.getLogger("suite.planner")
|
||||
|
||||
# 规划器可选用的图类型(与 prompt.py 的 builder 对应)
|
||||
ALLOWED_KINDS = [
|
||||
"white_bg", "key_features", "selling_pt", "material",
|
||||
"lifestyle", "multi_scene", "ecommerce_detail",
|
||||
"size_chart", "sku_collection", "custom",
|
||||
]
|
||||
|
||||
SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规划一套电商详情页/主图套图的出图方案。
|
||||
|
||||
## 规划规则
|
||||
1. SKU 主图:商品有多个带图 SKU(颜色/款式)时,每个 SKU 出 1 张独立主图(kind=white_bg),
|
||||
并在 variant_name 里填对应的 SKU 规格名(必须来自「SKU规格」列表,原样照抄);
|
||||
单 SKU 商品出 1 张主图即可(variant_name 留空)。
|
||||
2. 场景图(kind=lifestyle):按商品的核心使用场景出 2-4 张,每张聚焦一个场景,场景从描述/参数里提取。
|
||||
3. 细节图(kind=material 或 custom):按商品的关键细节/材质/结构出 2-3 张,每张聚焦一个卖点细节。
|
||||
4. 尺寸标注图(kind=size_chart):参数里有长宽高/尺寸数据时出 1 张。
|
||||
5. SKU 合集图(kind=sku_collection):SKU 数量 >1 时出 1 张,同款多色整齐排列。
|
||||
6. 可用 kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene /
|
||||
ecommerce_detail / size_chart / sku_collection / custom。其他创意图用 custom。
|
||||
7. 总张数控制在 8-15 张;每项 count 为 1-3。
|
||||
8. title 用中文短语(≤8字,如「主图·粉色」「浴室壁挂场景」);detail 用中文说明这张图要展示什么(≤40字);
|
||||
prompt_hint 用英文描述构图(角度/布局/光线要点,≤60 words),供生图模型使用。
|
||||
|
||||
## 输出格式(严格 JSON,不要多余文字)
|
||||
{
|
||||
"summary": "整体思路一句话",
|
||||
"items": [
|
||||
{"kind": "white_bg", "title": "主图·粉色", "detail": "粉色SKU白底主视觉", "prompt_hint": "front view on pure white background", "count": 1, "variant_name": "粉色"}
|
||||
]
|
||||
}"""
|
||||
|
||||
|
||||
def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
|
||||
"""清洗模型输出:kind 白名单、count 钳制、variant 必须真实存在。"""
|
||||
items: list[dict] = []
|
||||
for it in raw_items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
kind = str(it.get("kind") or "custom")
|
||||
if kind not in ALLOWED_KINDS:
|
||||
kind = "custom"
|
||||
title = str(it.get("title") or "").strip()[:20]
|
||||
if not title:
|
||||
continue
|
||||
try:
|
||||
count = max(0, min(3, int(it.get("count", 1))))
|
||||
except (TypeError, ValueError):
|
||||
count = 1
|
||||
variant = str(it.get("variant_name") or "").strip() or None
|
||||
if variant and variant not in sku_variants:
|
||||
variant = None # 幻觉规格:丢弃绑定,回退主图
|
||||
items.append({
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"detail": str(it.get("detail") or "").strip()[:80],
|
||||
"prompt_hint": str(it.get("prompt_hint") or "").strip()[:300],
|
||||
"count": count,
|
||||
"variant_name": variant,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
async def generate_plan(
|
||||
product_info: dict,
|
||||
sku_variants: list[str],
|
||||
image_stats: dict,
|
||||
platform: str,
|
||||
) -> dict:
|
||||
"""调用 DeepSeek 生成方案。返回 {summary, items}。"""
|
||||
s = get_settings()
|
||||
if not s.deepseek_api_key:
|
||||
raise RuntimeError("未配置 DEEPSEEK_API_KEY(.env)")
|
||||
|
||||
user_content = json.dumps({
|
||||
"商品信息": product_info, # {title, desc, params:[{key,value}], sellingPoints, price}
|
||||
"SKU规格": sku_variants, # 带图的 SKU 规格名(variant_name 只能从中选)
|
||||
"图片统计": image_stats, # {main: n, sku: n, detail: n}
|
||||
"目标平台": platform, # ozon/wb/cn(决定图内文案语言)
|
||||
}, ensure_ascii=False)
|
||||
|
||||
async with httpx.AsyncClient(timeout=60, verify=False) as client:
|
||||
resp = await client.post(
|
||||
f"{s.deepseek_base_url.rstrip('/')}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {s.deepseek_api_key}", "Content-Type": "application/json"},
|
||||
json={
|
||||
"model": s.deepseek_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 2000,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
content = resp.json()["choices"][0]["message"]["content"]
|
||||
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except json.JSONDecodeError as exc:
|
||||
log.error("规划器输出不是合法 JSON: %s", content[:200])
|
||||
raise RuntimeError("规划器输出解析失败") from exc
|
||||
|
||||
items = _normalize_items(data.get("items") or [], sku_variants)
|
||||
if not items:
|
||||
raise RuntimeError("规划器未返回有效方案项")
|
||||
# 总量保护:超过 18 张时按比例截断
|
||||
total = sum(i["count"] for i in items)
|
||||
while total > 18 and items:
|
||||
last = items[-1]
|
||||
if last["count"] > 1:
|
||||
last["count"] -= 1
|
||||
else:
|
||||
items.pop()
|
||||
total = sum(i["count"] for i in items)
|
||||
|
||||
return {"summary": str(data.get("summary") or "").strip()[:100], "items": items}
|
||||
@@ -0,0 +1,290 @@
|
||||
"""套图 Prompt 引擎。
|
||||
|
||||
借鉴 ecommerce-image-suite 的动态 Prompt 架构,浓缩为:
|
||||
- 7 种图类型 × 5 套视觉风格模板
|
||||
- 公共组件:QUALITY(画质)/ PRODUCT_REF_LOCK(商品一致性锁)/ TEXT_RENDER(图内文案规范)
|
||||
- 卖点从采集的参数表/卖点文本自动提炼
|
||||
|
||||
核心原则:所有图严格保持商品一致性(same silhouette, same print, same color),
|
||||
只允许改变背景 / 角度 / 光线 / 排版。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# ── 风格模板(与插件端 STYLE_SET_OPTIONS 对应)─────────────────────────────
|
||||
|
||||
STYLE_SETS: dict[int, dict] = {
|
||||
1: {
|
||||
"name": "经典商拍",
|
||||
"tone": "premium commercial e-commerce photography, clean soft studio lighting, "
|
||||
"gentle gradient background, catalog-grade presentation, refined and trustworthy",
|
||||
"bg": "light neutral studio backdrop with soft vignette",
|
||||
},
|
||||
2: {
|
||||
"name": "生活杂志",
|
||||
"tone": "editorial lifestyle magazine aesthetic, natural window light, "
|
||||
"cozy lived-in atmosphere, muted film tones, candid storytelling",
|
||||
"bg": "warm lifestyle home setting with plants and textured fabrics",
|
||||
},
|
||||
3: {
|
||||
"name": "极简高冷",
|
||||
"tone": "minimalist high-end aesthetic, vast negative space, single directional light, "
|
||||
"cool grey palette, architectural calm, quiet luxury",
|
||||
"bg": "seamless light grey studio background with subtle shadow",
|
||||
},
|
||||
4: {
|
||||
"name": "活力爆款",
|
||||
"tone": "vibrant high-conversion e-commerce style, punchy saturated accents, "
|
||||
"energetic composition, bold contrast, promotional poster energy",
|
||||
"bg": "bright colorful gradient backdrop with dynamic geometric shapes",
|
||||
},
|
||||
5: {
|
||||
"name": "暗调质感",
|
||||
"tone": "dark moody premium product photography, dramatic rim lighting, "
|
||||
"deep charcoal background, rich texture detail, luxurious atmosphere",
|
||||
"bg": "matte black background with soft spotlight and subtle smoke haze",
|
||||
},
|
||||
}
|
||||
|
||||
# ── 图类型中文名(导出文件名用)───────────────────────────────────────────
|
||||
|
||||
TYPE_NAMES_ZH: dict[str, str] = {
|
||||
"white_bg": "白底主图",
|
||||
"key_features": "核心卖点图",
|
||||
"selling_pt": "卖点图",
|
||||
"material": "材质图",
|
||||
"lifestyle": "场景展示图",
|
||||
"multi_scene": "多场景拼图",
|
||||
"ecommerce_detail": "电商详情图",
|
||||
"size_chart": "尺寸标注图",
|
||||
"sku_collection": "SKU合集图",
|
||||
"custom": "创意图",
|
||||
}
|
||||
|
||||
# ── 公共组件 ──────────────────────────────────────────────────────────────
|
||||
|
||||
QUALITY = (
|
||||
"Shot on Sony A7R V with 85mm lens at f/2.0, ultra-detailed, photorealistic, "
|
||||
"8K commercial image quality, professional retouching."
|
||||
)
|
||||
|
||||
PRODUCT_REF_LOCK = (
|
||||
"CRITICAL: The product must look EXACTLY the same as in the reference image — "
|
||||
"identical silhouette, proportions, colors, print pattern, stitching and every design detail. "
|
||||
"Only the background, camera angle, lighting and styling may change. "
|
||||
"Do not redesign, add or remove any element of the product."
|
||||
)
|
||||
|
||||
TEXT_RENDER = {
|
||||
"zh": (
|
||||
"Render concise Chinese marketing text inside the image: main headline max 8 Chinese characters, "
|
||||
"sub-lines max 12 characters each, font is modern clean sans-serif (Source Han Sans style), "
|
||||
"high legibility, tasteful typography layout, colors harmonized with the composition. "
|
||||
"No spelling errors, no garbled characters."
|
||||
),
|
||||
"en": (
|
||||
"Render concise English marketing text inside the image: headline max 5 words, "
|
||||
"sub-lines max 8 words each, Helvetica Neue style sans-serif, high legibility, "
|
||||
"tasteful typography layout, colors harmonized with the composition. No spelling errors."
|
||||
),
|
||||
"ru": (
|
||||
"Render concise Russian marketing text inside the image: headline max 4 words, "
|
||||
"sub-lines max 6 words each, modern clean sans-serif (Inter / PT Sans style), "
|
||||
"proper Cyrillic typography, high legibility, tasteful layout, colors harmonized with the composition. "
|
||||
"No spelling errors, no mixed latin/cyrillic gibberish."
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_NEGATIVE_INTENT = (
|
||||
"no AI-generated look, no CGI quality, no plastic appearance, no watermark, "
|
||||
"no distorted text, no deformed product, no extra limbs, no blurry areas"
|
||||
)
|
||||
|
||||
# ── 商品上下文提炼 ────────────────────────────────────────────────────────
|
||||
|
||||
def _shorten(text: str, n: int) -> str:
|
||||
text = re.sub(r"\s+", " ", (text or "")).strip()
|
||||
return text[:n]
|
||||
|
||||
def _clean_title(title: str) -> str:
|
||||
"""去掉常见堆砌词,让标题更可读。"""
|
||||
t = _shorten(title, 60)
|
||||
return re.sub(r"[【【】】\\[\\]|/]", " ", t).strip()
|
||||
|
||||
def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -> dict:
|
||||
"""从采集数据提炼生图上下文:标题、描述行、卖点列表、参数行。
|
||||
|
||||
raw: {title, desc, price, params: [{key, value}], sellingPoints}
|
||||
"""
|
||||
title = _clean_title(raw.get("title") or fallback_name or "product")
|
||||
desc = _shorten(raw.get("desc") or fallback_desc or "", 200)
|
||||
|
||||
# 卖点:优先显式卖点文本;否则从参数表里挑短而有信息量的键值对
|
||||
selling_points: list[dict] = []
|
||||
sp_text = raw.get("sellingPoints") or ""
|
||||
if sp_text:
|
||||
for chunk in re.split(r"[;;\n·]+|(?<!\d)\.(?!\d)", sp_text):
|
||||
c = _shorten(chunk, 20)
|
||||
if c and len(selling_points) < 5:
|
||||
selling_points.append({"zh": c, "en": c})
|
||||
if not selling_points:
|
||||
for p in (raw.get("params") or [])[:12]:
|
||||
k, v = _shorten(p.get("key", ""), 10), _shorten(str(p.get("value", "")), 16)
|
||||
if k and v and k.lower() not in {"货号", "sku", "isbn", "上架时间"}:
|
||||
selling_points.append({"zh": f"{k} {v}", "en": f"{k} {v}"})
|
||||
if len(selling_points) >= 5:
|
||||
break
|
||||
|
||||
params_line = "; ".join(
|
||||
f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8]
|
||||
)
|
||||
return {
|
||||
"title": title,
|
||||
"title_en": title, # 采集源多为中文标题,英文场景直接用原词避免乱翻译
|
||||
"desc": desc,
|
||||
"selling_points": selling_points[:3],
|
||||
"params_line": params_line,
|
||||
"price": raw.get("price") or "",
|
||||
}
|
||||
|
||||
def _sp_lines(ctx: dict, lang: str, max_n: int = 3) -> str:
|
||||
sps = ctx["selling_points"][:max_n]
|
||||
if not sps:
|
||||
return ""
|
||||
key = "zh" if lang == "zh" else "en"
|
||||
return "; ".join(s[key] for s in sps if s.get(key))
|
||||
|
||||
# ── 各图类型 Prompt ───────────────────────────────────────────────────────
|
||||
|
||||
def _prompt_white_bg(ctx: dict, style: dict, lang: str) -> str:
|
||||
return (
|
||||
f"E-commerce main product image on pure white background (RGB 255,255,255), "
|
||||
f"product \"{ctx['title']}\" centered and filling about 85% of the frame, "
|
||||
f"front view, even shadowless studio lighting with a faint natural contact shadow, "
|
||||
f"{style['tone']}. No text, no props, no background elements. {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_key_features(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = _sp_lines(ctx, lang) or ctx["title"]
|
||||
return (
|
||||
f"E-commerce key-features infographic for product \"{ctx['title']}\", square layout: "
|
||||
f"product on the left two-thirds ({style['bg']}), right column lists 3 feature callouts "
|
||||
f"with minimal line icons, thin leader lines pointing to product details. "
|
||||
f"Feature callouts: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_selling_pt(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = _sp_lines(ctx, lang, 1) or ctx["title"]
|
||||
return (
|
||||
f"Single-selling-point e-commerce poster for product \"{ctx['title']}\": "
|
||||
f"hero product close-up at dynamic angle ({style['bg']}), one large bold headline "
|
||||
f"about \"{sp}\", generous negative space, one small magnified detail circle "
|
||||
f"highlighting material or craft. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_material(ctx: dict, style: dict, lang: str) -> str:
|
||||
return (
|
||||
f"Macro material close-up of product \"{ctx['title']}\": extreme detail shot revealing "
|
||||
f"fabric weave / surface texture / stitching / finish, shallow depth of field, "
|
||||
f"raking light across the surface, {style['tone']}. Small caption label in corner. "
|
||||
f"{TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_lifestyle(ctx: dict, style: dict, lang: str) -> str:
|
||||
return (
|
||||
f"Lifestyle in-context scene for product \"{ctx['title']}\": the product is naturally "
|
||||
f"used / placed in a real environment ({style['bg']}), realistic human-scale surroundings, "
|
||||
f"soft daylight, authentic candid mood, product remains the clear visual focus. "
|
||||
f"{style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_multi_scene(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = _sp_lines(ctx, lang)
|
||||
return (
|
||||
f"Triptych multi-scene e-commerce image for product \"{ctx['title']}\": three vertical panels "
|
||||
f"separated by thin gutters, each panel shows the SAME product in a different usage scene "
|
||||
f"(e.g. home interior / outdoor street / office desk), consistent color grading across panels. "
|
||||
f"Panel captions: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_ecommerce_detail(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = _sp_lines(ctx, lang) or ctx["title"]
|
||||
params = ctx["params_line"]
|
||||
return (
|
||||
f"E-commerce detail-page hero section for product \"{ctx['title']}\", square layout: "
|
||||
f"top half is a hero banner with the product at a 3/4 angle ({style['bg']}); "
|
||||
f"bottom half is a clean spec card listing 3 feature rows with line icons"
|
||||
+ (f" (specs: {params})" if params else "")
|
||||
+ f" and one highlighted row: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_size_chart(ctx: dict, style: dict, lang: str) -> str:
|
||||
dims = ctx["params_line"]
|
||||
return (
|
||||
f"Product size chart infographic for \"{ctx['title']}\": product shown in clean front and side views "
|
||||
f"on light background, with thin measurement annotation lines (arrows) marking length, width and height, "
|
||||
f"measurement values rendered next to each line"
|
||||
+ (f" (known specs: {dims})" if dims else "")
|
||||
+ f", small caption row, precise technical drawing aesthetic. {style['tone']}. "
|
||||
f"{TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_sku_collection(ctx: dict, style: dict, lang: str) -> str:
|
||||
return (
|
||||
f"Colorway collection image for product \"{ctx['title']}\": the SAME product in all its color/variant "
|
||||
f"options arranged in a neat equal grid (2-4 items per row), each colorway with a small label chip below it, "
|
||||
f"consistent lighting and scale across all items, clean e-commerce presentation. "
|
||||
f"{style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_custom(ctx: dict, style: dict, lang: str, extra: dict) -> str:
|
||||
hint = (extra.get("prompt_hint") or "").strip()
|
||||
purpose = extra.get("title") or ""
|
||||
detail = extra.get("detail") or ""
|
||||
composed = (
|
||||
f"E-commerce marketing image for product \"{ctx['title']}\""
|
||||
+ (f" — {purpose}" if purpose else "")
|
||||
+ (f": {detail}" if detail else "")
|
||||
+ "."
|
||||
)
|
||||
if hint:
|
||||
composed += f" Composition: {hint}."
|
||||
return f"{composed} {style['tone']}. {style['bg']} as environment. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
|
||||
_PROMPT_BUILDERS = {
|
||||
"white_bg": _prompt_white_bg,
|
||||
"key_features": _prompt_key_features,
|
||||
"selling_pt": _prompt_selling_pt,
|
||||
"material": _prompt_material,
|
||||
"lifestyle": _prompt_lifestyle,
|
||||
"multi_scene": _prompt_multi_scene,
|
||||
"ecommerce_detail": _prompt_ecommerce_detail,
|
||||
"size_chart": _prompt_size_chart,
|
||||
"sku_collection": _prompt_sku_collection,
|
||||
}
|
||||
|
||||
|
||||
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None) -> str:
|
||||
"""构造指定图类型的完整生图 prompt。
|
||||
|
||||
extra: 方案项信息 {title, detail, prompt_hint}——custom 类型必需,
|
||||
预设类型也会把 prompt_hint 作为构图补充注入。
|
||||
"""
|
||||
style = STYLE_SETS.get(style_set, STYLE_SETS[1])
|
||||
extra = extra or {}
|
||||
if type_id == "custom":
|
||||
prompt = _prompt_custom(ctx, style, lang, extra)
|
||||
else:
|
||||
builder = _PROMPT_BUILDERS.get(type_id)
|
||||
if builder is None:
|
||||
raise ValueError(f"未知图类型: {type_id}")
|
||||
prompt = builder(ctx, style, lang)
|
||||
hint = (extra.get("prompt_hint") or "").strip()
|
||||
if hint:
|
||||
prompt = prompt.rstrip(".") + f". Additional composition guidance: {hint}."
|
||||
return prompt + ". " + DEFAULT_NEGATIVE_INTENT
|
||||
|
||||
|
||||
def type_name(type_id: str) -> str:
|
||||
return TYPE_NAMES_ZH.get(type_id, type_id)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""本地文件存储:落 data/media/,由 FastAPI /media 静态托管。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from config import get_settings
|
||||
|
||||
|
||||
def media_root() -> Path:
|
||||
root = Path(get_settings().data_dir) / "media"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def public_url(key: str) -> str:
|
||||
"""media key → 可访问 URL。"""
|
||||
settings = get_settings()
|
||||
return f"{settings.app_base_url.rstrip('/')}/media/{key}"
|
||||
|
||||
|
||||
def _ext_from_url_or_type(hint: str, content_type: str = "") -> str:
|
||||
if content_type:
|
||||
ctype = content_type.split(";")[0].strip().lower()
|
||||
mapping = {
|
||||
"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp",
|
||||
"image/gif": ".gif", "image/bmp": ".bmp", "video/mp4": ".mp4",
|
||||
}
|
||||
if ctype in mapping:
|
||||
return mapping[ctype]
|
||||
ext = mimetypes.guess_extension(hint.split("?")[0].lower()) or ".jpg"
|
||||
return ".jpg" if ext == ".jpe" else ext
|
||||
|
||||
|
||||
def write_bytes(data: bytes, key_prefix: str = "", ext: str = ".jpg") -> str:
|
||||
"""写文件,返回 media key(相对 media 根的路径)。"""
|
||||
key = f"{key_prefix + '/' if key_prefix else ''}{uuid.uuid4().hex}{ext}"
|
||||
path = media_root() / key
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return key
|
||||
|
||||
|
||||
async def download_bytes(url: str, referer: str | None = None, timeout: float = 60.0) -> tuple[bytes, str]:
|
||||
"""下载远程字节。返回 (bytes, content_type)。"""
|
||||
headers = {"Referer": referer} if referer else {}
|
||||
headers.setdefault("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)")
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, verify=False) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip()
|
||||
return resp.content, ctype
|
||||
|
||||
|
||||
async def save_from_url(url: str, key_prefix: str = "", referer: str | None = None) -> str:
|
||||
data, ctype = await download_bytes(url, referer)
|
||||
key = write_bytes(data, key_prefix, _ext_from_url_or_type(url, ctype))
|
||||
return public_url(key)
|
||||
|
||||
|
||||
def local_path(stored_url_or_key: str) -> Path | None:
|
||||
"""stored_url(http.../media/xxx)或 key → 本地文件路径。"""
|
||||
s = stored_url_or_key
|
||||
if "/media/" in s:
|
||||
s = s.split("/media/", 1)[1]
|
||||
p = media_root() / s
|
||||
return p if p.exists() else None
|
||||
Reference in New Issue
Block a user