feat: 添加新的模型,删除后端数据库
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
# 模型名优先路由:已知模型直接定位 provider(gpt-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\""},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user