feat: 添加新的模型,删除后端数据库

This commit is contained in:
Joey
2026-08-19 22:37:20 +08:00
parent 82cb694837
commit 6732cb178a
17 changed files with 411 additions and 918 deletions
-166
View File
@@ -1,166 +0,0 @@
"""采集入库:插件上传文本 + 图片 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)}
+13 -18
View File
@@ -1,11 +1,9 @@
"""无状态套图生成:请求自带采集数据,不落商品库"""
"""无状态套图生成:请求自带采集数据,任务存进程内注册表"""
from __future__ import annotations
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi import APIRouter, BackgroundTasks, HTTPException
from config import get_settings
from db import get_db
from models import Suite
from schemas import (
GenerateRequest, PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, RIGHTAPI_MODELS,
SuiteCreateResponse, TextMaterial, resolve_provider,
@@ -14,6 +12,7 @@ from schemas import (
from services.generator import run_suite
from services.planner import generate_plan
from services.prompt import type_name
from services.tasks import create_task
router = APIRouter(prefix="/api", tags=["generate"])
@@ -48,7 +47,6 @@ def texts_to_raw(texts: list[TextMaterial]) -> dict:
async def generate_suite(
req: GenerateRequest,
background: BackgroundTasks,
db=Depends(get_db),
) -> SuiteCreateResponse:
if not req.images:
raise HTTPException(status_code=400, detail="未勾选任何图片,无法生成")
@@ -72,7 +70,6 @@ async def generate_suite(
})
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]
@@ -92,19 +89,20 @@ async def generate_suite(
raise HTTPException(status_code=400, detail=f"不支持的模型: {model}tongyi 支持: {TONGYI_MODELS}")
if provider_name == "rightapi" and model and model not in RIGHTAPI_MODELS:
raise HTTPException(status_code=400, detail=f"不支持的模型: {model}rightapi 支持: {RIGHTAPI_MODELS}")
suite = Suite(
product_id=None,
style_set=req.style_set,
style_prompt=req.style_prompt,
requirements=req.requirements,
task = create_task(
status="pending",
platform=req.platform,
lang=spec["lang"],
ratio=spec["ratio"],
types=types,
plan=jobs,
style_set=req.style_set,
style_prompt=req.style_prompt,
requirements=req.requirements,
provider=provider_name,
model=model,
total=len(jobs),
context=texts_to_raw(req.texts),
plan=jobs,
# 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配)
ref_images=[
{
@@ -115,12 +113,9 @@ async def generate_suite(
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))
background.add_task(run_suite, task)
return SuiteCreateResponse(suite_id=task.id)
@router.post("/plan", response_model=PlanResponse)
-71
View File
@@ -1,71 +0,0 @@
"""商品查询 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,
)
+29 -112
View File
@@ -1,143 +1,60 @@
"""套图生成 API创建任务 / 查询状态 / 导出 ZIP"""
"""套图任务 API轮询进度 / 导出 ZIP(进程内内存任务表)"""
from __future__ import annotations
import io
import zipfile
from uuid import UUID
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi import APIRouter, 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, TONGYI_MODELS, RIGHTAPI_MODELS,
SuiteCreateRequest, SuiteCreateResponse, SuiteImageOut, SuiteOut, resolve_provider,
)
from schemas import SuiteImageOut, SuiteOut
from services import storage
from services.generator import run_suite
from services.tasks import IMG_OK, Task, get_task
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()
def _task_out(task: Task) -> SuiteOut:
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,
model=suite.model,
id=task.id,
status=task.status,
style_set=task.style_set,
platform=task.platform,
lang=task.lang,
ratio=task.ratio,
provider=task.provider,
model=task.model,
total=task.total,
images=[
SuiteImageOut(
type_id=i.type_id, name=i.name, url=i.stored_url or "",
type_id=i.type_id, name=i.name, url=i.url,
status=i.status, error=i.error,
) for i in images
) for i in task.images
],
error=suite.error,
error=task.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()
# 模型名优先路由:已知模型直接定位 providergpt-image-2 → rightapi
provider_name = resolve_provider(req.model, req.provider, settings.image_provider)
if provider_name == "tongyi" and req.model and req.model not in TONGYI_MODELS:
raise HTTPException(status_code=400, detail=f"不支持的模型: {req.model}tongyi 支持: {TONGYI_MODELS}")
if provider_name == "rightapi" and req.model and req.model not in RIGHTAPI_MODELS:
raise HTTPException(status_code=400, detail=f"不支持的模型: {req.model}rightapi 支持: {RIGHTAPI_MODELS}")
suite = Suite(
product_id=product.id,
style_set=req.style_set,
requirements=req.requirements,
platform=req.platform,
lang=spec["lang"],
ratio=spec["ratio"],
types=req.types,
provider=provider_name,
model=req.model,
)
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]
async def get_suite(suite_id: str):
task = get_task(suite_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在(服务可能已重启),请重新生成")
return _task_out(task)
@router.get("/suites/{suite_id}/zip")
async def download_suite_zip(suite_id: str, db: AsyncSession = Depends(get_db)):
async def download_suite_zip(suite_id: str):
"""把任务内所有成功图打包成 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()
task = get_task(suite_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在(服务可能已重启)")
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 "")
for i, img in enumerate([i for i in task.images if i.status == IMG_OK]):
path = storage.local_path(img.url or "")
if path is None:
continue
filename = img.name or img.type_id
@@ -149,5 +66,5 @@ async def download_suite_zip(suite_id: str, db: AsyncSession = Depends(get_db)):
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="suite-{suite_id}.zip"'},
headers={"Content-Disposition": f"attachment; filename=\"suite-{suite_id}.zip\""},
)
-59
View File
@@ -1,59 +0,0 @@
"""数据库:SQLiteaiosqlite+ SQLAlchemy async。"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from sqlalchemy import text
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 _migrate(conn) -> None:
"""给旧库补充新增列(create_all 不会修改已存在的表)。"""
rows = await conn.execute(text("PRAGMA table_info(suites)"))
cols = {row[1] for row in rows}
if "model" not in cols:
await conn.execute(text("ALTER TABLE suites ADD COLUMN model VARCHAR(64)"))
if "requirements" not in cols:
await conn.execute(text("ALTER TABLE suites ADD COLUMN requirements TEXT"))
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)
await _migrate(conn)
+4 -15
View File
@@ -2,27 +2,18 @@
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, upload
from api import generate, proxy, suites, upload
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 = FastAPI(title="电商套图工作台", version="0.1.0")
app.add_middleware(
CORSMiddleware,
@@ -31,14 +22,12 @@ app.add_middleware(
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(suites.router)
app.include_router(proxy.router)
app.include_router(upload.router)
# 静态托管生成的图片/转存素材
# 静态托管生成的图片
app.mount("/media", StaticFiles(directory=str(media_root())), name="media")
-119
View File
@@ -1,119 +0,0 @@
"""数据模型: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
style_prompt: Mapped[str | None] = mapped_column(Text, nullable=True) # 用户改写的风格提示词(覆盖模板)
requirements: Mapped[str | None] = mapped_column(Text, nullable=True) # 生图要求(最高优先级,强制约束)
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")
model: Mapped[str | None] = mapped_column(String(64), nullable=True) # 生图模型名(覆盖 provider 默认)
# 工具化流程:请求自带的数据(生图上下文 + 参考图 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())
-2
View File
@@ -1,7 +1,5 @@
fastapi>=0.110
uvicorn[standard]>=0.29
sqlalchemy[asyncio]>=2.0
aiosqlite>=0.20
pydantic>=2.6
pydantic-settings>=2.2
httpx>=0.27
+12 -76
View File
@@ -12,8 +12,15 @@ SUPPORTED_TYPES = [
# 通义(DashScope)生图模型白名单:插件下拉可选的模型
TONGYI_MODELS = ["qwen-image-3.0-pro", "wan2.7-image-pro", "wan2.6-image", "wan2.6-t2i"]
# RightAPI 生图模型白名单
RIGHTAPI_MODELS = ["gpt-image-2"]
# RightAPI 生图模型白名单gpt-image 系列 + Google nano-banana 系列,同一中转)
RIGHTAPI_MODELS = [
"gpt-image-2",
"gpt-image-2-vip",
"nano-banana",
"nano-banana-2",
"nano-banana-2-lite",
"nano-banana-pro",
]
# 模型 → provider 推断表:插件只传模型名,服务端据此路由(模型名优先于 provider 字段)
MODEL_PROVIDERS: dict[str, str] = {
@@ -29,14 +36,7 @@ def resolve_provider(model: str | None, requested: str | None, default: str) ->
return requested or default
# ── 采集上传 ──
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")
@@ -44,30 +44,6 @@ class TextMaterial(BaseModel):
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
# ── 套图生成 ──
# 目标平台 → 文案语言 + 图片比例(平台决定规格,不再单独选语言)
@@ -78,15 +54,6 @@ PLATFORM_SPECS: dict[str, dict] = {
}
class SuiteCreateRequest(BaseModel):
style_set: int = Field(default=1, ge=1, le=7, description="风格模板 1-7")
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="覆盖默认 providerdoubao | tongyi")
model: str | None = Field(default=None, description="覆盖默认生图模型(tongyi: qwen-image-3.0-pro / wan2.7-image-pro")
requirements: str | None = Field(default=None, description="生图要求(最高优先级,强制约束)")
# ── 无状态套图生成(工具流程:请求自带采集数据)──
class GenerateImageItem(BaseModel):
@@ -108,7 +75,7 @@ class PlanItem(BaseModel):
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=7)
style_set: int = Field(default=1, ge=1, le=5)
style_prompt: str | None = Field(default=None, description="用户改写的风格提示词(覆盖 style_set 模板)")
requirements: str | None = Field(default=None, description="生图要求(最高优先级,强制约束,覆盖其他设定)")
types: list[str] = Field(default_factory=list, description="旧参数:无方案时按类型生成")
@@ -156,45 +123,14 @@ class SuiteImageOut(BaseModel):
class SuiteOut(BaseModel):
id: str
product_id: str
status: str
style_set: int
platform: str
lang: str
ratio: str
types: list[str]
provider: str
model: str | None = None
total: int = 0 # 计划生成总张数(进度分母;images 是逐张追加,过程中 length < total
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]
+51 -109
View File
@@ -11,16 +11,13 @@ import base64
import logging
import mimetypes
import re
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, wrap_prompt_for_gpt_edits
from services.tasks import Task, TaskImage, TASK_FAILED, TASK_RUNNING, TASK_DONE, TASK_PARTIAL, IMG_OK
log = logging.getLogger("suite.generator")
@@ -242,22 +239,25 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*
# 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
# 中转对 input_fidelity 参数的支持探测:None=未探测,True=支持,False=不支持(已降级)
_rightapi_fidelity_supported: bool | None = None
# 中转对 input_fidelity 参数的支持探测:按模型记忆不支持该参数的模型(gpt-image 系列支持,
# nano-banana 系列可能不认;降级只影响触发过的模型,不牵连其他模型)
_rightapi_fidelity_unsupported: set[str] = set()
async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes:
"""gpt-image 系列:有参考图走 /v1/images/editsmultipart),无参考图走 /v1/images/generations。
"""RightAPI 各模型:有参考图走 /v1/images/editsmultipart),无参考图走 /v1/images/generations。
OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400);
同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。
input_fidelity=high 强制高保真保留输入图细节(商品一致性关键参数,仅 edits 端点);
中转若不认该参数(400),自动去掉重试并记住,后续请求不再带。
input_fidelity=high 是 gpt-image-1 的 edits 保真参数(gpt-image-2 官方已移除、默认高保真,
官逆通道更是不识别);带上是为了兼容按 gpt-image-1 语义实现的中转,中转不认(400)则按模型
自动去掉重试并记住,该模型后续请求不再带。
"""
global _rightapi_fidelity_supported
base = s.rightapi_base_url.rstrip("/")
headers = {"Authorization": f"Bearer {s.rightapi_api_key}"}
use_fidelity = bool(ref_images) and s.rightapi_input_fidelity and _rightapi_fidelity_supported is not False
use_fidelity = (
bool(ref_images) and s.rightapi_input_fidelity and model not in _rightapi_fidelity_unsupported
)
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
common = {
@@ -276,14 +276,12 @@ async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, mo
data, mime = await _resolve_ref_bytes(u)
files.append(("image[]", (f"ref-{i + 1}.{mime.split('/')[-1]}", data, mime)))
resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common)
# 中转不认 input_fidelity:去掉参数重试一次(仅一次探测)
# 中转不认 input_fidelity:去掉参数重试一次(仅一次探测),降级只记到当前模型
if resp.status_code == 400 and use_fidelity and "input_fidelity" in resp.text:
_rightapi_fidelity_supported = False
log.warning("RightAPI 不支持 input_fidelity 参数,已自动去掉并降级(后续请求不再带)")
_rightapi_fidelity_unsupported.add(model)
log.warning("RightAPI 模型 %s 不支持 input_fidelity 参数,已自动去掉并降级(该模型后续请求不再带)", model)
common.pop("input_fidelity", None)
resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common)
elif resp.is_success and use_fidelity:
_rightapi_fidelity_supported = True
else:
resp = await client.post(
f"{base}/v1/images/generations",
@@ -365,123 +363,67 @@ def _refs_for_job(images: list[dict], job: dict) -> list[str]:
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)
# 串行生成队列:所有用户共享同一批 API key,并发生成会触发中转限流
# rightapi 同 key 分钟级冷却);同一时间只跑一个任务,其余保持 pending 排队。
_GEN_LOCK = asyncio.Lock()
async def run_suite(suite_id: str) -> None:
"""后台执行套图任务:逐张生成 → 落盘 → 记录;单张失败不中断。
两条路径:
- 无状态(product_id 为空):上下文与参考图来自请求自带的 context / ref_images
- 商品路径(兼容旧流程):从 product + product_assets 取
"""
async def run_suite(task: Task) -> None:
"""后台执行套图任务:排队 → 逐张生成 → 落盘 → 更新内存状态;单张失败不中断。"""
settings = get_settings()
async with get_session_factory()() as db:
suite = await db.get(Suite, UUID(suite_id))
if suite is None:
return
provider_name = task.provider or settings.image_provider
generator = GENERATORS.get(provider_name)
if generator is None:
task.status = TASK_FAILED
task.error = f"未知 provider: {provider_name}"
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 "")
model = suite.model or {
"tongyi": settings.dashscope_model,
"rightapi": settings.rightapi_image_model,
}.get(provider_name, settings.ark_image_model)
is_wan = provider_name == "tongyi" and _is_wan_model(model)
size = _image_size(provider_name, suite.ratio, is_wan=is_wan, model=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 [])
]
ctx = build_context(task.context or {}, fallback_name="")
model = task.model or {
"tongyi": settings.dashscope_model,
"rightapi": settings.rightapi_image_model,
}.get(provider_name, settings.ark_image_model)
is_wan = provider_name == "tongyi" and _is_wan_model(model)
size = _image_size(provider_name, task.ratio, is_wan=is_wan, model=model)
jobs = [dict(j) for j in task.plan]
async with _GEN_LOCK:
task.status = TASK_RUNNING
ok, failed = 0, 0
failures: list[str] = []
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()
image = TaskImage(type_id=type_id, name=job.get("title") or type_name(type_id))
task.images.append(image)
try:
prompt = build_prompt(
type_id, ctx, suite.style_set, suite.lang,
extra=job, style_prompt=suite.style_prompt, requirements=suite.requirements,
type_id, ctx, task.style_set, task.lang,
extra=job, style_prompt=task.style_prompt, requirements=task.requirements,
)
# gpt-image edits 语义:商品冻结契约前置,防止风格词改商品
# gpt-image edits 语义:商品冻结契约前置(含商品文字锚定),防止风格词改商品
if provider_name == "rightapi":
prompt = wrap_prompt_for_gpt_edits(prompt)
if product:
refs = await _select_ref_images(db, product.id, type_id)
else:
refs = _refs_for_job(list(suite.ref_images or []), job)
prompt = wrap_prompt_for_gpt_edits(prompt, ctx)
refs = _refs_for_job(list(task.ref_images or []), job)
data = await generator(prompt, refs, size=size, model=model)
# 部分中转不遵守 output_format(要 jpeg 回 PNG),按魔数定扩展名
ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
key = storage.write_bytes(data, key_prefix=f"suites/{suite.id}", ext=ext)
image_row.stored_url = storage.public_url(key)
image_row.status = STATUS_OK
key = storage.write_bytes(data, key_prefix=f"suites/{task.id}", ext=ext)
image.url = storage.public_url(key)
image.status = IMG_OK
ok += 1
except Exception as exc: # noqa: BLE001
log.exception("套图 %s 类型 %s 生成失败", suite_id, type_id)
err = str(exc)[:500]
image_row.error = err
failures.append(f"{job.get('title') or type_name(type_id)}{err[:200]}")
log.exception("套图 %s 类型 %s 生成失败", task.id, type_id)
image.error = str(exc)[:500]
failures.append(f"{job.get('title') or type_name(type_id)}{str(exc)[:200]}")
failed += 1
await db.commit()
suite.status = SUITE_DONE if failed == 0 else (SUITE_PARTIAL if ok > 0 else SUITE_FAILED)
task.status = TASK_DONE if failed == 0 else (TASK_PARTIAL if ok > 0 else TASK_FAILED)
if failed:
uniq = list(dict.fromkeys(failures)) # 去重保序
detail = "".join(uniq[:6])
if len(uniq) > 6:
detail += f";…等共 {failed} 张失败"
if ok == 0:
suite.error = f"全部生成失败。{detail}"
task.error = f"全部生成失败。{detail}"
else:
suite.error = f"部分生成失败({failed} 张)。{detail}"
from datetime import datetime, timezone
suite.finished_at = datetime.now(timezone.utc)
if product:
product.stage = "generated" # 商品路径才有的阶段升级
await db.commit()
task.error = f"部分生成失败({failed} 张)。{detail}"
+47 -50
View File
@@ -17,47 +17,28 @@ import re
STYLE_SETS: dict[int, dict] = {
1: {
"name": "高级质感大片",
"tone": "高端电商大片质感,柔和的方向性棚拍光,背景带细腻的浅渐变,材质纹理清晰可见,"
"色彩层次高级克制,商业画册级品质,构图干净、留白充足",
"name": "北欧极简",
"tone": "北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净",
"bg": "",
},
2: {
"name": "清新生活场景",
"tone": "明亮通透的生活场景摄影,自然窗光,柔和的低饱和居家环境,浅景深虚化,"
"真实自然的氛围感,绿植与暖色织物点缀,温馨有人气",
"name": "清新明亮",
"tone": "清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净",
"bg": "",
},
3: {
"name": "极简白底规范",
"tone": "极简棚拍风格,纯净无缝的浅色背景,柔和均匀的无影布光,以商品为中心的严谨构图,"
"安静的高级感,画面只保留轻微的自然接触投影",
"name": "高级感深色",
"tone": "高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级",
"bg": "",
},
4: {
"name": "炫彩促销风",
"tone": "高能量促销风格,高饱和度色块背景搭配动感几何图形,强对比,节日大促海报氛围,"
"构图抢眼、视觉冲击力强",
"name": "暖调生活",
"tone": "温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强",
"bg": "",
},
5: {
"name": "暗调轻奢",
"tone": "暗调轻奢质感,深炭灰色背景,轮廓光勾勒商品边缘,材质细节丰富,带轻微雾感,"
"如美术馆展陈般的呈现",
"bg": "",
},
6: {
"name": "俄式风情",
"tone": "俄式风情电商大片,浓郁温暖的色调,红与金的传统配色点缀,冬日节庆氛围,"
"深色木质与毛毡织物背景,如暖炉烛光般的柔和光晕,厚重扎实的质感,"
"带一丝巴洛克式的华丽细节,适合俄语区市场",
"bg": "",
},
7: {
"name": "北欧极简",
"tone": "北欧极简风格,白色与浅灰的原木空间,大量自然漫射光,干净利落的线条,"
"浅色木质背景点缀少量绿植,克制的中性配色,画面通透轻盈,"
"舒适宁静的氛围",
"name": "纯净棚拍",
"tone": "标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出",
"bg": "",
},
}
@@ -121,33 +102,49 @@ DEFAULT_NEGATIVE_INTENT = (
# "主体参考"),风格词会被字面执行到商品上。按 OpenAI 官方提示词指南的编辑模式:
# 按序号说明输入图、PRESERVE/MAY CHANGE 分列、首尾重申不变量、文案逐字渲染。
GPT_EDITS_CONTRACT = (
"INPUT IMAGES: Image 1 (and Image 2 if present) are reference photos of ONE product "
"from different angles. Use them ONLY as the source of the product's true appearance.\n"
"PRESERVE (frozen, never change): the product itself — silhouette, proportions, colors, "
"print/pattern (keep stripes / logos / labels exactly), materials, texture, stitching, "
"hardware and every design detail. The product in the output must be the same physical "
"item as in the input images, merely photographed in a new setting.\n"
"MAY CHANGE: background, scene, props, camera angle, lighting, composition "
"and in-image marketing typography.\n"
"STYLE SCOPE: all style, mood, color-palette and decoration instructions below describe "
"the SCENE AND BACKGROUND ONLY — never apply them to the product itself. "
"Do not restyle, recolor, re-pattern or redecorate the product. "
"You may relight the product so it sits naturally in the new scene "
"(matched shadows and color temperature), but never change its design, colors or pattern."
)
def gpt_edits_contract(ctx: dict) -> str:
"""gpt-image edits 语义契约(放开头,指令权重最高处)。
除通用锁定条款外,注入商品文字锚定(标题 + 关键参数 + 描述):
官逆通道(gpt-image-2-vip 等)会把参考图当对话附件弱化处理,
input_fidelity 类 API 参数不生效,此时商品文字描述是保真的唯一兜底。
"""
anchor = f" The product is: \"{ctx['title']}\""
if ctx.get("params_line"):
anchor += f" (key specs: {ctx['params_line']})"
if ctx.get("desc"):
anchor += f". {ctx['desc']}"
return (
"TASK: Edit the attached product photos — re-photograph THE SAME physical product "
"in a new setting. This is an edit of the input images, NOT a new product design.\n"
"INPUT IMAGES: Image 1 = product front view (PRIMARY source of truth for the product's "
f"true appearance); Image 2 (if present) = product back / detail view.{anchor}\n"
"PRODUCT LOCK (highest priority, overrides everything else in this prompt): exactly "
"preserve the product's shape, silhouette, proportions, colors, label text, logos, "
"print/pattern, materials and texture. Do not redesign, restyle, recolor, re-pattern "
"or substitute the product with a similar one. The output must show the very product "
"from the input images AND match the product description above; if the generated "
"product differs from either in any design detail, the image is rejected.\n"
"MAY CHANGE: background, scene, props, camera angle, lighting, composition "
"and in-image marketing typography only. You may relight the product so it sits "
"naturally in the new scene (matched shadows and color temperature).\n"
"STYLE SCOPE: all style, mood, color-palette and decoration instructions below describe "
"the SCENE AND BACKGROUND ONLY — never apply them to the product. "
"When any style instruction conflicts with product fidelity, product fidelity always wins."
)
GPT_EDITS_FINAL_CHECK = (
"FINAL CHECK before output: if the product in your result differs from the input product in any "
"design detail (shape, color, pattern, material, logo), the image is rejected. "
"Render any listed marketing copy / headlines exactly as written (verbatim, no extra characters, "
"no paraphrasing)."
"FINAL CHECK before output: if the product in your result differs from the input product "
"or the product description above in any design detail (shape, color, pattern, material, "
"logo), the image is rejected. Render any listed marketing copy / headlines exactly as "
"written (verbatim, no extra characters, no paraphrasing)."
)
def wrap_prompt_for_gpt_edits(prompt: str) -> str:
def wrap_prompt_for_gpt_edits(prompt: str, ctx: dict) -> str:
"""gpt-image edits 语义适配:契约放开头(指令权重最高处),终检放结尾。"""
return f"{GPT_EDITS_CONTRACT}\n\n{prompt}\n\n{GPT_EDITS_FINAL_CHECK}"
return f"{gpt_edits_contract(ctx)}\n\n{prompt}\n\n{GPT_EDITS_FINAL_CHECK}"
# ── 商品上下文提炼 ────────────────────────────────────────────────────────
+68
View File
@@ -0,0 +1,68 @@
"""内存任务注册表:套图生成任务的生命周期与进程一致(重启即新会话)。
轮询/导出只服务「当前会话正在跟踪的任务」——前端没有历史记录功能,
任务状态无需跨进程持久化;重启后轮询自然 404,前端提示任务已中断。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
# 任务状态
TASK_PENDING = "pending"
TASK_RUNNING = "running"
TASK_DONE = "done"
TASK_PARTIAL = "partial"
TASK_FAILED = "failed"
# 任务内单张图状态
IMG_OK = "ok"
IMG_FAILED = "failed"
@dataclass
class TaskImage:
"""任务里单张生成图:完成一张追加一条(前端进度 x/y 依赖此语义)。"""
type_id: str
name: str
status: str = IMG_FAILED # 循环里先建后跑,成功后改为 ok
url: str = ""
error: str | None = None
@dataclass
class Task:
"""一次套图生成任务:轮询可见字段 + 仅供 run_suite 消费的执行参数。"""
id: str
status: str = TASK_PENDING
platform: str = "cn"
lang: str = "zh"
ratio: str = "1:1"
style_set: int = 1
style_prompt: str | None = None
requirements: str | None = None
provider: str = ""
model: str | None = None
total: int = 0 # 计划总张数(进度分母)
images: list[TaskImage] = field(default_factory=list)
error: str | None = None
# ── 执行参数(不进轮询响应)──
context: dict = field(default_factory=dict) # 采集文本素材(build_context 的输入)
plan: list[dict] = field(default_factory=list) # 展开后的逐张任务
ref_images: list[dict] = field(default_factory=list) # 参考图池(main 优先)
# 进程内任务表:asyncio 单事件循环读写,无并发问题;不做淘汰(单会话量级很小)
_TASKS: dict[str, Task] = {}
def create_task(**kwargs) -> Task:
task = Task(id=uuid.uuid4().hex, **kwargs)
_TASKS[task.id] = task
return task
def get_task(task_id: str) -> Task | None:
return _TASKS.get(task_id)