feat: 优化图片资源删除管理,生图任务列表管理
This commit is contained in:
+89
-31
@@ -10,14 +10,17 @@ from __future__ import annotations
|
||||
import io
|
||||
import mimetypes
|
||||
import re
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.proxy import guess_referer
|
||||
from services.storage import download_bytes, local_path
|
||||
from config import get_settings
|
||||
from services.storage import ResourceSizeLimitError, download_bytes, local_path, read_local_bytes
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["export"])
|
||||
|
||||
@@ -39,7 +42,7 @@ class ExportImageItem(BaseModel):
|
||||
|
||||
class ExportImagesRequest(BaseModel):
|
||||
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
|
||||
images: list[ExportImageItem]
|
||||
images: list[ExportImageItem] = Field(min_length=1)
|
||||
|
||||
|
||||
def _clean(name: str) -> str:
|
||||
@@ -75,47 +78,102 @@ async def _download(url: str) -> tuple[bytes, str]:
|
||||
path = local_path(url)
|
||||
if path is not None:
|
||||
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
||||
return path.read_bytes(), mime
|
||||
return read_local_bytes(path), mime
|
||||
return await download_bytes(url, referer=guess_referer(url))
|
||||
|
||||
|
||||
def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
|
||||
"""以固定块流式返回临时 ZIP,响应结束(或中断)后关闭临时文件。"""
|
||||
try:
|
||||
while chunk := handle.read(chunk_size):
|
||||
yield chunk
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
|
||||
@router.post("/export/images")
|
||||
async def export_images(req: ExportImagesRequest):
|
||||
if not req.images:
|
||||
raise HTTPException(status_code=400, detail="没有可导出的图片")
|
||||
settings = get_settings()
|
||||
if len(req.images) > settings.export_max_images:
|
||||
raise HTTPException(status_code=413, detail=f"一次最多导出 {settings.export_max_images} 张图片,请分批导出")
|
||||
|
||||
root = _clean(req.title) or "采集图片"
|
||||
buf = io.BytesIO()
|
||||
# 小 ZIP 留在内存,大 ZIP 自动滚动到临时磁盘;响应阶段按块传输。
|
||||
archive = tempfile.SpooledTemporaryFile(
|
||||
max_size=settings.export_zip_memory_threshold_bytes,
|
||||
mode="w+b",
|
||||
)
|
||||
used: set[str] = set()
|
||||
total_source_bytes = 0
|
||||
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for img in req.images:
|
||||
try:
|
||||
data, ctype = await _download(img.url)
|
||||
except Exception: # noqa: BLE001
|
||||
continue # 单张失败不中断整包
|
||||
if not _is_image(img.url, ctype):
|
||||
continue
|
||||
try:
|
||||
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for img in req.images:
|
||||
try:
|
||||
data, ctype = await _download(img.url)
|
||||
except ResourceSizeLimitError as exc:
|
||||
raise HTTPException(status_code=413, detail=f"无法导出「{img.key or '图片'}」:{exc}") from exc
|
||||
except Exception: # noqa: BLE001
|
||||
continue # 单张下载失败不中断整包
|
||||
if not _is_image(img.url, ctype):
|
||||
continue
|
||||
total_source_bytes += len(data)
|
||||
if total_source_bytes > settings.export_zip_max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"导出图片总大小超过 {settings.export_zip_max_bytes // 1024 // 1024} MB,请减少勾选图片后重试",
|
||||
)
|
||||
|
||||
ext = _ext(img.url, ctype)
|
||||
base = _clean(img.key) or "image"
|
||||
if img.variantName:
|
||||
base += f"-{_clean(img.variantName)}"
|
||||
filename = f"{base}{ext}"
|
||||
if filename in used: # 同名加序号防覆盖
|
||||
stem = filename[: -len(ext)]
|
||||
n = 2
|
||||
while f"{stem}-{n}{ext}" in used:
|
||||
n += 1
|
||||
filename = f"{stem}-{n}{ext}"
|
||||
used.add(filename)
|
||||
ext = _ext(img.url, ctype)
|
||||
base = _clean(img.key) or "image"
|
||||
if img.variantName:
|
||||
base += f"-{_clean(img.variantName)}"
|
||||
filename = f"{base}{ext}"
|
||||
if filename in used: # 同名加序号防覆盖
|
||||
stem = filename[: -len(ext)]
|
||||
n = 2
|
||||
while f"{stem}-{n}{ext}" in used:
|
||||
n += 1
|
||||
filename = f"{stem}-{n}{ext}"
|
||||
used.add(filename)
|
||||
|
||||
group = _clean(img.groupName) or "图片"
|
||||
zf.writestr(f"{root}/{group}/{filename}", data)
|
||||
group = _clean(img.groupName) or "图片"
|
||||
zf.writestr(f"{root}/{group}/{filename}", data)
|
||||
if archive.tell() > settings.export_zip_max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"导出 ZIP 超过 {settings.export_zip_max_bytes // 1024 // 1024} MB,请减少勾选图片后重试",
|
||||
)
|
||||
except Exception:
|
||||
archive.close()
|
||||
raise
|
||||
|
||||
buf.seek(0)
|
||||
archive.seek(0)
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
_stream_file(archive),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": 'attachment; filename="collect.zip"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export/image")
|
||||
async def export_image(img: ExportImageItem):
|
||||
"""代理下载单张图片,兼容源站防盗链与本地媒体路径。"""
|
||||
try:
|
||||
data, ctype = await _download(img.url)
|
||||
except ResourceSizeLimitError as exc:
|
||||
raise HTTPException(status_code=413, detail=f"图片文件过大,无法下载:{exc}") from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=422, detail="图片下载失败") from exc
|
||||
if not _is_image(img.url, ctype):
|
||||
raise HTTPException(status_code=400, detail="该地址不是图片")
|
||||
|
||||
ext = _ext(img.url, ctype)
|
||||
base = _clean(img.key) or "image"
|
||||
if img.variantName:
|
||||
base += f"-{_clean(img.variantName)}"
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=ctype,
|
||||
headers={"Content-Disposition": f'attachment; filename="{base}{ext}"'},
|
||||
)
|
||||
|
||||
+165
-19
@@ -2,9 +2,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Form
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -12,8 +14,38 @@ from db import get_db, get_session_factory
|
||||
from models import Product, ProductAsset
|
||||
from models.enums import AssetStatus, Stage
|
||||
from schemas.collection import MaterialsRequest, MaterialsResponse, TextMaterial
|
||||
from schemas.suite import SuiteOut, WatermarkOptions
|
||||
from services.image_assets import WatermarkUpdate, get_image_asset_service
|
||||
from services.jobs import create_image_job, get_job, list_product_jobs, suite_view
|
||||
from config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["collection"])
|
||||
log = logging.getLogger("materials")
|
||||
|
||||
|
||||
class ApplyWatermarkRequest(BaseModel):
|
||||
"""对已落库图片批量应用水印;始终基于 original 重新合成。"""
|
||||
|
||||
asset_ids: list[str] = Field(min_length=1, max_length=50)
|
||||
watermark: WatermarkOptions
|
||||
|
||||
|
||||
class ApplyWatermarkResponse(BaseModel):
|
||||
"""批量水印已进入后台队列;前端以 job_id 查询逐图进度。"""
|
||||
|
||||
job_id: str
|
||||
total: int
|
||||
|
||||
|
||||
class RemoveWatermarkResponse(BaseModel):
|
||||
"""同步移除水印的逐项处理结果。"""
|
||||
|
||||
updated_ids: list[str]
|
||||
failed: list[dict[str, str]]
|
||||
|
||||
|
||||
class RemoveWatermarkRequest(BaseModel):
|
||||
asset_ids: list[str] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
def _parse_number(text: str | None) -> float | None:
|
||||
@@ -115,6 +147,11 @@ async def create_materials(
|
||||
background: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> MaterialsResponse:
|
||||
if len(req.images) > get_settings().collection_max_images:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"一次最多采集 {get_settings().collection_max_images} 个素材,请分批上报",
|
||||
)
|
||||
product = await _get_or_create_product(db, req)
|
||||
_apply_texts(product, req.texts)
|
||||
|
||||
@@ -176,9 +213,7 @@ async def create_materials(
|
||||
|
||||
async def process_product_assets(product_id: str) -> None:
|
||||
"""后台:下载 pending 素材 → 转存 storage。失败逐张标记,不中断。"""
|
||||
from services.storage import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
image_assets = get_image_asset_service()
|
||||
async with get_session_factory()() as db:
|
||||
assets = (await db.scalars(
|
||||
select(ProductAsset).where(
|
||||
@@ -190,8 +225,7 @@ async def process_product_assets(product_id: str) -> None:
|
||||
a.status = AssetStatus.downloading
|
||||
await db.commit()
|
||||
try:
|
||||
stored = await storage.save_from_url(a.source_url, key_prefix="assets")
|
||||
a.stored_url = stored
|
||||
await image_assets.save_original_from_url(a, a.source_url)
|
||||
a.status = AssetStatus.uploaded
|
||||
except Exception as exc: # noqa: BLE001
|
||||
a.status = AssetStatus.failed
|
||||
@@ -213,7 +247,17 @@ async def upload_material_bytes(
|
||||
product = await db.get(Product, UUID(product_id))
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="商品不存在")
|
||||
data = await file.read()
|
||||
limit = get_settings().image_upload_max_bytes
|
||||
if file.size is not None and file.size > limit:
|
||||
raise HTTPException(status_code=413, detail=f"图片文件过大,单张最多 {limit // 1024 // 1024} MB")
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
if total > limit:
|
||||
raise HTTPException(status_code=413, detail=f"图片文件过大,单张最多 {limit // 1024 // 1024} MB")
|
||||
chunks.append(chunk)
|
||||
data = b"".join(chunks)
|
||||
asset = ProductAsset(
|
||||
product_id=product.id,
|
||||
group_key=group_key,
|
||||
@@ -225,11 +269,10 @@ async def upload_material_bytes(
|
||||
)
|
||||
db.add(asset)
|
||||
await db.flush()
|
||||
# 直接转存字节
|
||||
from services.storage import get_storage
|
||||
storage = get_storage()
|
||||
# 直接转存为素材唯一原图(current 与 original 初始指向同一对象)
|
||||
image_assets = get_image_asset_service()
|
||||
try:
|
||||
asset.stored_url = await storage.save_bytes(data, f"assets/{asset.id}", file.content_type or "")
|
||||
await image_assets.save_original(asset, data, file.content_type or "")
|
||||
asset.status = AssetStatus.uploaded
|
||||
except Exception as exc: # noqa: BLE001
|
||||
asset.status = AssetStatus.failed
|
||||
@@ -269,20 +312,16 @@ async def is_collected(
|
||||
|
||||
@router.delete("/assets/{asset_id}")
|
||||
async def delete_asset(asset_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""删除一条素材记录(DB 行 + 本地文件;七牛等远程存储仅删引用)。"""
|
||||
from services.storage import local_path
|
||||
"""删除一条素材记录及其 original/current 七牛对象。"""
|
||||
|
||||
asset = await db.get(ProductAsset, UUID(asset_id))
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=404, detail="素材不存在")
|
||||
|
||||
if asset.stored_url:
|
||||
path = local_path(asset.stored_url)
|
||||
if path is not None and path.is_file():
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError: # noqa: BLE001
|
||||
pass # 文件删除失败不阻断记录删除
|
||||
try:
|
||||
await get_image_asset_service().delete_asset_media(asset)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"图片存储删除失败,素材未删除:{exc}") from exc
|
||||
|
||||
product_id = asset.product_id
|
||||
group_key = asset.group_key
|
||||
@@ -298,3 +337,110 @@ async def delete_asset(asset_id: str, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
await db.commit()
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.post("/assets/watermark", response_model=ApplyWatermarkResponse)
|
||||
async def apply_assets_watermark(
|
||||
req: ApplyWatermarkRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApplyWatermarkResponse:
|
||||
"""创建批量水印后台任务;Worker 从原图逐张合成并切换当前展示版。"""
|
||||
if not req.watermark.enabled:
|
||||
raise HTTPException(status_code=400, detail="请先开启水印")
|
||||
|
||||
try:
|
||||
asset_ids = list(dict.fromkeys(UUID(asset_id) for asset_id in req.asset_ids))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="包含非法素材 ID") from exc
|
||||
|
||||
assets = (await db.scalars(select(ProductAsset).where(ProductAsset.id.in_(asset_ids)))).all()
|
||||
by_id = {asset.id: asset for asset in assets}
|
||||
missing = [str(asset_id) for asset_id in asset_ids if asset_id not in by_id]
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail=f"素材不存在:{', '.join(missing[:3])}")
|
||||
|
||||
product_ids = {asset.product_id for asset in assets}
|
||||
if len(product_ids) != 1:
|
||||
raise HTTPException(status_code=400, detail="一次批量水印只能处理同一商品的素材")
|
||||
product_id = str(next(iter(product_ids)))
|
||||
task = await create_image_job(
|
||||
product_id=product_id,
|
||||
kind="watermark_batch",
|
||||
# 水印在后台低优先级执行,不阻塞单张编辑和套图生成。
|
||||
priority=-10,
|
||||
provider="local",
|
||||
model=None,
|
||||
platform="ozon",
|
||||
lang="",
|
||||
ratio="",
|
||||
style_set=0,
|
||||
request_payload={"watermark": req.watermark.model_dump()},
|
||||
item_payloads=[
|
||||
{
|
||||
"position": index,
|
||||
"kind": "watermark",
|
||||
"title": f"水印:{str(asset.id)[:8]}",
|
||||
"asset_id": str(asset.id),
|
||||
# 任务排队时冻结当前版本;若同图被其他操作更新,Worker 不会覆盖新版本。
|
||||
"expected_current_storage_key": asset.current_storage_key,
|
||||
"expected_current_url": asset.stored_url,
|
||||
}
|
||||
for index, asset in enumerate(by_id[asset_id] for asset_id in asset_ids)
|
||||
],
|
||||
)
|
||||
return ApplyWatermarkResponse(job_id=str(task.id), total=task.total)
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/watermark-jobs", response_model=list[SuiteOut])
|
||||
async def list_watermark_jobs(product_id: str, active_only: bool = False):
|
||||
"""试算页刷新后恢复正在执行的批量水印任务。"""
|
||||
jobs = await list_product_jobs(product_id, active_only=active_only, kinds=("watermark_batch",))
|
||||
views: list[SuiteOut] = []
|
||||
for job in jobs:
|
||||
result = await get_job(str(job.id))
|
||||
if result is not None:
|
||||
views.append(SuiteOut(**suite_view(*result)))
|
||||
return views
|
||||
|
||||
|
||||
@router.post("/assets/watermark/remove", response_model=RemoveWatermarkResponse)
|
||||
async def remove_assets_watermark(
|
||||
req: RemoveWatermarkRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RemoveWatermarkResponse:
|
||||
"""批量移除水印:current 切回 original,并删除当前水印对象。"""
|
||||
try:
|
||||
asset_ids = list(dict.fromkeys(UUID(asset_id) for asset_id in req.asset_ids))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="包含非法素材 ID") from exc
|
||||
|
||||
assets = (await db.scalars(select(ProductAsset).where(ProductAsset.id.in_(asset_ids)))).all()
|
||||
by_id = {asset.id: asset for asset in assets}
|
||||
missing = [str(asset_id) for asset_id in asset_ids if asset_id not in by_id]
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail=f"素材不存在:{', '.join(missing[:3])}")
|
||||
|
||||
image_assets = get_image_asset_service()
|
||||
updated_ids: list[str] = []
|
||||
failed: list[dict[str, str]] = []
|
||||
obsolete: list[WatermarkUpdate] = []
|
||||
for asset_id in asset_ids:
|
||||
asset = by_id[asset_id]
|
||||
if asset.type != "img":
|
||||
failed.append({"asset_id": str(asset_id), "error": "仅支持图片素材"})
|
||||
continue
|
||||
if not asset.watermark_enabled:
|
||||
continue
|
||||
try:
|
||||
obsolete.append(await image_assets.remove_watermark(asset))
|
||||
updated_ids.append(str(asset_id))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed.append({"asset_id": str(asset_id), "error": str(exc)[:200]})
|
||||
|
||||
await db.commit()
|
||||
for update in obsolete:
|
||||
try:
|
||||
await image_assets.delete_key(update.obsolete_key)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("水印已移除,但旧水印对象删除失败:%s", update.obsolete_key)
|
||||
return RemoveWatermarkResponse(updated_ids=updated_ids, failed=failed)
|
||||
|
||||
@@ -134,6 +134,18 @@ async def delete_product(
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="商品不存在")
|
||||
if hard:
|
||||
# 先阻止后台 Worker 回写新生成素材,再删除已有图片与商品记录。
|
||||
from services.jobs import cancel_product_jobs
|
||||
await cancel_product_jobs(str(product.id))
|
||||
assets = (await db.scalars(
|
||||
select(ProductAsset).where(ProductAsset.product_id == product.id)
|
||||
)).all()
|
||||
try:
|
||||
from services.image_assets import get_image_asset_service
|
||||
|
||||
await get_image_asset_service().delete_product_media(str(product.id), assets)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"图片存储删除失败,商品未删除:{exc}") from exc
|
||||
await db.delete(product)
|
||||
else:
|
||||
product.stage = Stage.archived
|
||||
@@ -160,6 +172,9 @@ async def list_assets(
|
||||
"type": a.type,
|
||||
"source_url": a.source_url,
|
||||
"stored_url": a.stored_url,
|
||||
"original_url": a.original_url,
|
||||
"watermark_enabled": a.watermark_enabled,
|
||||
"watermark_config": a.watermark_config,
|
||||
"status": a.status.value,
|
||||
"width": a.width,
|
||||
"height": a.height,
|
||||
|
||||
+3
-1
@@ -8,7 +8,7 @@ from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
|
||||
from services.storage import download_bytes
|
||||
from services.storage import ResourceSizeLimitError, download_bytes
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["proxy"])
|
||||
|
||||
@@ -40,6 +40,8 @@ async def proxy_image(url: str = Query(..., description="源站图片 URL")):
|
||||
raise HTTPException(status_code=400, detail="仅支持 http/https URL")
|
||||
try:
|
||||
data, ctype = await download_bytes(url, referer=guess_referer(url))
|
||||
except ResourceSizeLimitError as exc:
|
||||
raise HTTPException(status_code=413, detail=f"图片文件过大,无法代理:{exc}") from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"图片拉取失败: {exc}") from exc
|
||||
if not ctype.startswith("image/"):
|
||||
|
||||
+188
-116
@@ -1,7 +1,7 @@
|
||||
"""套图规划 / 一键生成 / 任务查询 / 结果导出 / 单张 AI 生图。
|
||||
|
||||
按 docs/v2.1/api.md §2-6 实现:引擎平移自 image-suite-studio(services/planner|generator|
|
||||
tasks|watermark + services/prompts),任务存进程内内存表(重启即失效)。
|
||||
watermark + services/prompts),任务持久化到数据库,由独立 Worker 执行。
|
||||
生成图回调回写 product_assets(group_key='generated');product_id 缺省时只落盘不回写。
|
||||
业务逻辑(texts_to_raw / 模型校验 / generated 回写)在 services/suite_service.py。
|
||||
注:V2.1 阶段本组接口暂不接鉴权(现有鉴权后续可能重做)。
|
||||
@@ -9,11 +9,11 @@ tasks|watermark + services/prompts),任务存进程内内存表(重启即
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from functools import partial
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from collections.abc import Iterator
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from config import get_settings
|
||||
@@ -21,7 +21,7 @@ from schemas.suite import (
|
||||
PLATFORM_SPECS,
|
||||
SUPPORTED_TYPES,
|
||||
ImageEditSingleRequest,
|
||||
ImageEditSingleResponse,
|
||||
ImageEditTaskResponse,
|
||||
PlanItemOut,
|
||||
PlanResponse,
|
||||
SuiteCreateResponse,
|
||||
@@ -31,16 +31,34 @@ from schemas.suite import (
|
||||
api_model,
|
||||
resolve_provider,
|
||||
)
|
||||
from services.generator import _image_size, GENERATORS, run_suite
|
||||
from services.generator import GENERATORS
|
||||
from services.jobs import (
|
||||
ITEM_DONE,
|
||||
cancel_job,
|
||||
create_image_job,
|
||||
get_job,
|
||||
list_product_jobs,
|
||||
retry_failed_items,
|
||||
retry_failed_item,
|
||||
suite_view,
|
||||
)
|
||||
from services.planner import generate_plan
|
||||
from services.prompts import build_context, build_prompt, type_name
|
||||
from services.storage import download_bytes, get_storage, local_path
|
||||
from services.suite_service import append_generated_asset, texts_to_raw, validate_model
|
||||
from services.tasks import IMG_OK, TaskImage, create_task, get_task
|
||||
from services.prompts import type_name
|
||||
from services.storage import ResourceSizeLimitError, download_bytes, local_path, read_local_bytes
|
||||
from services.suite_service import texts_to_raw, validate_model
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["suite"])
|
||||
|
||||
|
||||
def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
|
||||
"""以固定块返回临时 ZIP,释放响应完成后的临时文件。"""
|
||||
try:
|
||||
while chunk := handle.read(chunk_size):
|
||||
yield chunk
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
|
||||
# ── 出图方案规划 ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/suite/plan", response_model=PlanResponse)
|
||||
@@ -64,7 +82,7 @@ async def plan_suite(req: SuitePlanRequest) -> PlanResponse:
|
||||
# ── 一键生成 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/suite/generate", response_model=SuiteCreateResponse)
|
||||
async def generate_suite(req: SuiteGenerateRequest, background: BackgroundTasks) -> SuiteCreateResponse:
|
||||
async def generate_suite(req: SuiteGenerateRequest) -> SuiteCreateResponse:
|
||||
if not req.images:
|
||||
raise HTTPException(status_code=400, detail="未勾选任何图片,无法生成")
|
||||
|
||||
@@ -111,94 +129,146 @@ async def generate_suite(req: SuiteGenerateRequest, background: BackgroundTasks)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
|
||||
|
||||
task = create_task(
|
||||
status="pending",
|
||||
task = await create_image_job(
|
||||
product_id=product_id,
|
||||
kind="image_suite",
|
||||
priority=0,
|
||||
provider=provider_name,
|
||||
model=model,
|
||||
platform=req.platform,
|
||||
lang=spec["lang"],
|
||||
ratio=spec["ratio"],
|
||||
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,
|
||||
watermark=req.watermark.model_dump() if req.watermark else None,
|
||||
# 参考图池: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)
|
||||
],
|
||||
request_payload={
|
||||
"context": texts_to_raw(req.texts),
|
||||
"style_prompt": req.style_prompt,
|
||||
"requirements": req.requirements,
|
||||
"watermark": req.watermark.model_dump() if req.watermark else None,
|
||||
"append": bool(product_id),
|
||||
# main 组优先;variant_name 精确匹配由 Worker 复用原有选图规则。
|
||||
"ref_images": [
|
||||
{"url": image.url, "group_key": image.group_key, "variant_name": image.variant_name}
|
||||
for image in sorted(req.images, key=lambda image: 0 if image.group_key == "main" else 1)
|
||||
],
|
||||
},
|
||||
item_payloads=[{**job, "position": index} for index, job in enumerate(jobs)],
|
||||
)
|
||||
|
||||
# 每张成功即回写 generated 组;未关联商品时仅落存储不回写。
|
||||
# partial 绑定 product_id:run_suite 回调只传 image,签名须为 (image)
|
||||
background.add_task(
|
||||
run_suite,
|
||||
task,
|
||||
partial(append_generated_asset, product_id) if product_id else None,
|
||||
)
|
||||
return SuiteCreateResponse(suite_id=task.id)
|
||||
return SuiteCreateResponse(suite_id=str(task.id))
|
||||
|
||||
|
||||
# ── 任务查询 / 结果导出 ──────────────────────────────────────────────────
|
||||
|
||||
@router.get("/suites/{suite_id}", response_model=SuiteOut)
|
||||
async def get_suite(suite_id: str):
|
||||
task = get_task(suite_id)
|
||||
result = await get_job(suite_id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return SuiteOut(**suite_view(*result))
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/suites", response_model=list[SuiteOut])
|
||||
async def list_suites(product_id: str, active_only: bool = False):
|
||||
"""试算页恢复商品的未完成任务;后续任务中心可复用。"""
|
||||
jobs = await list_product_jobs(product_id, active_only=active_only, kinds=("image_suite", "image_edit"))
|
||||
views: list[SuiteOut] = []
|
||||
for job in jobs:
|
||||
result = await get_job(str(job.id))
|
||||
if result is not None:
|
||||
views.append(SuiteOut(**suite_view(*result)))
|
||||
return views
|
||||
|
||||
|
||||
@router.post("/suites/{suite_id}/cancel", response_model=SuiteOut)
|
||||
async def cancel_suite(suite_id: str):
|
||||
task = await cancel_job(suite_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在(服务可能已重启),请重新生成")
|
||||
return SuiteOut(
|
||||
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=[
|
||||
{"type_id": i.type_id, "name": i.name, "url": i.url, "status": i.status, "error": i.error}
|
||||
for i in task.images
|
||||
],
|
||||
error=task.error,
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
result = await get_job(str(task.id))
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return SuiteOut(**suite_view(*result))
|
||||
|
||||
|
||||
@router.post("/suites/{suite_id}/retry", response_model=SuiteOut)
|
||||
async def retry_suite(suite_id: str):
|
||||
task = await retry_failed_items(suite_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
result = await get_job(str(task.id))
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return SuiteOut(**suite_view(*result))
|
||||
|
||||
|
||||
@router.post("/suites/{suite_id}/items/{item_id}/retry", response_model=SuiteOut)
|
||||
async def retry_suite_item(suite_id: str, item_id: str):
|
||||
task = await retry_failed_item(suite_id, item_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务或子项不存在")
|
||||
result = await get_job(str(task.id))
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return SuiteOut(**suite_view(*result))
|
||||
|
||||
|
||||
@router.get("/suites/{suite_id}/zip")
|
||||
async def download_suite_zip(suite_id: str):
|
||||
"""把任务内所有成功图打包成 ZIP(中文文件名)。"""
|
||||
task = get_task(suite_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在(服务可能已重启)")
|
||||
result = await get_job(suite_id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
task, task_items = result
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
seen: set[str] = set()
|
||||
for i, img in enumerate([i for i in task.images if i.status == IMG_OK]):
|
||||
path = local_path(img.url or "")
|
||||
if path is not None:
|
||||
data, suffix = path.read_bytes(), path.suffix or ".jpg"
|
||||
else:
|
||||
try: # 七牛等远程存储:直接拉取公开 URL
|
||||
data, _ = await download_bytes(img.url or "")
|
||||
settings = get_settings()
|
||||
images = [image for image in task_items if image.status == ITEM_DONE and image.result_url]
|
||||
if len(images) > settings.export_max_images:
|
||||
raise HTTPException(status_code=413, detail=f"该任务有 {len(images)} 张图片,单次最多导出 {settings.export_max_images} 张")
|
||||
|
||||
archive = tempfile.SpooledTemporaryFile(
|
||||
max_size=settings.export_zip_memory_threshold_bytes,
|
||||
mode="w+b",
|
||||
)
|
||||
total_source_bytes = 0
|
||||
try:
|
||||
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
seen: set[str] = set()
|
||||
for i, img in enumerate(images):
|
||||
path = local_path(img.result_url or "")
|
||||
try:
|
||||
if path is not None:
|
||||
data, suffix = read_local_bytes(path), path.suffix or ".jpg"
|
||||
else: # 七牛等远程存储:直接拉取公开 URL
|
||||
data, _ = await download_bytes(img.result_url or "")
|
||||
suffix = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
|
||||
except ResourceSizeLimitError as exc:
|
||||
raise HTTPException(status_code=413, detail=f"无法导出「{img.name}」:{exc}") from exc
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
suffix = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
|
||||
filename = img.name or img.type_id
|
||||
if filename in seen: # 同类型多张时加序号防覆盖
|
||||
filename = f"{filename}-{i + 1}"
|
||||
seen.add(filename)
|
||||
zf.writestr(f"{filename}{suffix}", data)
|
||||
buf.seek(0)
|
||||
|
||||
total_source_bytes += len(data)
|
||||
if total_source_bytes > settings.export_zip_max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"导出图片总大小超过 {settings.export_zip_max_bytes // 1024 // 1024} MB,请减少图片后重试",
|
||||
)
|
||||
payload = img.payload or {}
|
||||
filename = payload.get("title") or payload.get("kind") or "AI 生图"
|
||||
if filename in seen: # 同类型多张时加序号防覆盖
|
||||
filename = f"{filename}-{i + 1}"
|
||||
seen.add(filename)
|
||||
zf.writestr(f"{filename}{suffix}", data)
|
||||
if archive.tell() > settings.export_zip_max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"导出 ZIP 超过 {settings.export_zip_max_bytes // 1024 // 1024} MB,请减少图片后重试",
|
||||
)
|
||||
except Exception:
|
||||
archive.close()
|
||||
raise
|
||||
|
||||
archive.seek(0)
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
_stream_file(archive),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f"attachment; filename=\"suite-{suite_id}.zip\""},
|
||||
)
|
||||
@@ -206,9 +276,9 @@ async def download_suite_zip(suite_id: str):
|
||||
|
||||
# ── 单张 AI 生图(V2.1 新增,ISS 无对应实现)──────────────────────────────
|
||||
|
||||
@router.post("/suite/image-edit", response_model=ImageEditSingleResponse)
|
||||
async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleResponse:
|
||||
"""轻量单张再生成:原图 + 一句话要求 + 模型选择。同步接口(单张可长达数分钟)。"""
|
||||
@router.post("/suite/image-edit", response_model=ImageEditTaskResponse)
|
||||
async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse:
|
||||
"""提交单张图生图任务;立即返回任务 ID,实际生成在统一队列异步执行。"""
|
||||
settings = get_settings()
|
||||
provider_name = resolve_provider(req.model, None, settings.image_provider)
|
||||
if provider_name not in GENERATORS:
|
||||
@@ -217,39 +287,41 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleRespon
|
||||
|
||||
spec = {"lang": "ru", "ratio": "3:4"} # 试算页固定 Ozon 规格(俄文图内文案 · 3:4)
|
||||
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
|
||||
is_wan = provider_name == "tongyi" and model.lower().startswith("wan")
|
||||
|
||||
# 复用套图的 prompt 家族:custom 类型装配「用户要求」为最高优先级约束,
|
||||
# 商品外观仍由参考图锁定(保真语义与一键生成一致)。
|
||||
ctx = build_context({"title": "AI 精修"}, fallback_name="product")
|
||||
prompt = build_prompt(
|
||||
provider_name, model, "custom", ctx,
|
||||
style_set=5, lang=spec["lang"],
|
||||
extra={"title": "AI 精修", "detail": req.prompt, "prompt_hint": ""},
|
||||
requirements=req.prompt,
|
||||
)
|
||||
|
||||
try:
|
||||
generator = GENERATORS[provider_name]
|
||||
data = await generator(prompt, [req.image_url], size=_image_size(provider_name, spec["ratio"], is_wan=is_wan, model=model), model=model)
|
||||
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
|
||||
|
||||
is_png = data[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
ext = ".png" if is_png else ".jpg"
|
||||
ctype = "image/png" if is_png else "image/jpeg"
|
||||
url = await get_storage().save_bytes(data, f"suites/single/{uuid.uuid4().hex}{ext}", ctype)
|
||||
|
||||
asset_id: str | None = None
|
||||
if req.append and req.product_id:
|
||||
product_id = (req.product_id or "").strip() or None
|
||||
if product_id:
|
||||
try:
|
||||
asset_id = await append_generated_asset(
|
||||
req.product_id,
|
||||
TaskImage(type_id="custom", name="AI生图", url=url, status="ok"),
|
||||
after_asset_id=req.after_asset_id,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass # 回写失败不影响结果返回,图片已在任务网格可见
|
||||
return ImageEditSingleResponse(url=url, asset_id=asset_id)
|
||||
uuid.UUID(product_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
|
||||
|
||||
# 单图编辑优先级高于套图;prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
|
||||
task = await create_image_job(
|
||||
product_id=product_id,
|
||||
kind="image_edit",
|
||||
priority=10,
|
||||
provider=provider_name,
|
||||
model=model,
|
||||
platform="ozon",
|
||||
lang=spec["lang"],
|
||||
ratio=spec["ratio"],
|
||||
style_set=5,
|
||||
request_payload={
|
||||
"context": {"title": "AI 精修"},
|
||||
"style_prompt": None,
|
||||
"requirements": req.prompt,
|
||||
"watermark": req.watermark.model_dump() if req.watermark else None,
|
||||
"append": bool(req.append and product_id),
|
||||
"after_asset_id": req.after_asset_id,
|
||||
"ref_images": [{"url": req.image_url, "group_key": "main", "variant_name": None}],
|
||||
},
|
||||
item_payloads=[{
|
||||
"position": 0,
|
||||
"kind": "custom",
|
||||
"title": "AI生图",
|
||||
"detail": "",
|
||||
"prompt_hint": "",
|
||||
"variant_name": None,
|
||||
"prompt_extra": {"title": "", "detail": "", "prompt_hint": ""},
|
||||
}],
|
||||
)
|
||||
return ImageEditTaskResponse(suite_id=str(task.id))
|
||||
|
||||
Reference in New Issue
Block a user