abc / api /product_visual.py
xiaolongmr
init
85270f9
Raw History Blame Contribute Delete
7.55 kB
"""商品视觉 API 路由(Product Visual)
对齐 Xiaoye AI 商品图工作台能力:
- POST /api/product-visual/tasks 创建商品素材包/详情页任务
- GET /api/product-visual/tasks 查询任务列表(?ids=a,b)
- GET /api/product-visual/tasks/{task_id} 查询单个任务
- POST /api/product-visual/tasks/{task_id}/items/{seq}/retry 单张重试
请求体(multipart/form-data):
- client_task_id: str
- prompt: str 商品描述/卖点/规格
- scenario: asset_pack | detail_page
- platform: auto | taobao_tmall | jd | pdd | douyin | xiaohongshu | amazon | tiktok_shop | shopee
- output_count: 6 | 8 | 10 | 12
- model: str (默认 gpt-image-2)
- image: 文件(一张或多张商品图,字段名 image,可重复)
"""
from __future__ import annotations
from fastapi import APIRouter, Header, HTTPException, Query, Request, Response
from fastapi.concurrency import run_in_threadpool
from api.image_inputs import parse_image_edit_request, read_image_sources
from api.support import require_identity, resolve_image_base_url
from services.product_visual_service import PLATFORM_SPECS, product_visual_service
def _clean(value: object, default: str = "") -> str:
return str(value if value is not None else default).strip()
def _extract_product_visual_fields(payload: dict[str, object]) -> dict[str, object]:
"""从请求载荷中提取商品视觉参数,非法值回退默认。"""
scenario = _clean(payload.get("scenario"), "asset_pack").lower()
if scenario not in {"asset_pack", "detail_page"}:
scenario = "asset_pack"
platform = _clean(payload.get("platform"), "auto").lower()
if platform not in PLATFORM_SPECS:
platform = "auto"
try:
output_count = int(payload.get("output_count") or 8)
except (TypeError, ValueError):
output_count = 8
if output_count not in {6, 8, 10, 12}:
output_count = 8
return {"scenario": scenario, "platform": platform, "output_count": output_count}
def create_router() -> APIRouter:
router = APIRouter()
@router.post("/api/product-visual/tasks")
async def create_product_visual_task(
request: Request,
authorization: str | None = Header(default=None),
):
identity = require_identity(authorization)
payload, image_sources, _mask_sources = await parse_image_edit_request(request)
client_task_id = str(payload.get("client_task_id") or "").strip()
if not client_task_id:
raise HTTPException(status_code=400, detail={"error": "client_task_id is required"})
prompt = str(payload.get("prompt") or "").strip()
if not prompt:
raise HTTPException(status_code=400, detail={"error": "prompt is required"})
fields = _extract_product_visual_fields(payload)
scenario = fields["scenario"]
platform = fields["platform"]
output_count = fields["output_count"]
model = str(payload.get("model") or "gpt-image-2").strip()
mode = str(payload.get("mode") or "concurrent").strip().lower()
images = await read_image_sources(image_sources)
try:
return await run_in_threadpool(
product_visual_service.submit,
identity,
client_task_id=client_task_id,
scenario=scenario,
platform=platform,
output_count=output_count,
prompt=prompt,
images=images,
model=model,
mode=mode,
base_url=resolve_image_base_url(request),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
@router.get("/api/product-visual/tasks")
async def list_product_visual_tasks(
ids: str = Query(default=""),
authorization: str | None = Header(default=None),
):
identity = require_identity(authorization)
task_ids = [item.strip() for item in ids.split(",") if item.strip()]
return await run_in_threadpool(product_visual_service.list_tasks, identity, task_ids)
@router.get("/api/product-visual/tasks/{task_id}")
async def get_product_visual_task(
task_id: str,
authorization: str | None = Header(default=None),
):
identity = require_identity(authorization)
task = await run_in_threadpool(product_visual_service.get_task, identity, task_id)
if task is None:
raise HTTPException(status_code=404, detail={"error": "商品视觉任务不存在"})
return task
@router.get("/api/product-visual/tasks/{task_id}/source-images/{index}")
async def get_product_visual_source_image(
task_id: str,
index: int,
authorization: str | None = Header(default=None),
):
identity = require_identity(authorization)
source = await run_in_threadpool(
product_visual_service.get_source_image,
identity,
task_id,
index,
)
if source is None:
raise HTTPException(status_code=404, detail={"error": "参考原图不存在"})
content, media_type, filename = source
return Response(
content=content,
media_type=media_type,
headers={
"Cache-Control": "private, max-age=86400",
"Content-Disposition": f'inline; filename="{filename}"',
},
)
@router.post("/api/product-visual/tasks/{task_id}/items/{seq}/retry")
async def retry_product_visual_item(
task_id: str,
seq: int,
request: Request,
authorization: str | None = Header(default=None),
):
identity = require_identity(authorization)
try:
return await run_in_threadpool(
product_visual_service.retry_item,
identity,
task_id,
seq,
resolve_image_base_url(request),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
@router.post("/api/product-visual/tasks/{task_id}/items/{seq}/iterate")
async def iterate_product_visual_item(
task_id: str,
seq: int,
request: Request,
authorization: str | None = Header(default=None),
):
identity = require_identity(authorization)
try:
payload = await request.json()
except Exception: # noqa: BLE001
raise HTTPException(status_code=400, detail={"error": "请求体必须是 JSON"}) from None
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail={"error": "请求体必须是 JSON"}) from None
revision = _clean(payload.get("revision"))
if not revision:
raise HTTPException(status_code=400, detail={"error": "修改意见不能为空"})
mask_data_url = _clean(payload.get("mask"))
try:
return await run_in_threadpool(
product_visual_service.iterate_item,
identity,
task_id,
seq,
revision,
resolve_image_base_url(request),
mask_data_url=mask_data_url,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
return router