File size: 7,547 Bytes
638cad0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | """商品视觉 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
|