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}"'},
)