feat: 优化图片资源删除管理,生图任务列表管理

This commit is contained in:
2026-08-30 14:50:33 +08:00
parent 4ee667142b
commit 957289f717
46 changed files with 4180 additions and 524 deletions
+89 -31
View File
@@ -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
View File
@@ -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)
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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))