feat: 初始化项目,并且接近完成 ozon 部分

This commit is contained in:
Joey
2026-08-15 22:19:27 +08:00
commit 1591d5e35a
46 changed files with 9827 additions and 0 deletions
View File
+166
View File
@@ -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)}
+126
View File
@@ -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"]],
)
+71
View File
@@ -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,
)
+54
View File
@@ -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": "*",
},
)
+141
View File
@@ -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"'},
)