Refine seller store implementation

This commit is contained in:
R524809
2026-08-31 14:37:28 +08:00
parent 957289f717
commit 2a667b4f3b
27 changed files with 1650 additions and 624 deletions
+29 -3
View File
@@ -11,8 +11,17 @@ import uuid
from dataclasses import dataclass
from api.proxy import guess_referer
from config import get_settings
from models import ProductAsset
from services.storage import download_bytes, get_storage, image_extension, local_path, read_local_bytes, storage_key_from_url
from services.storage import (
download_bytes,
get_storage,
image_extension,
local_path,
normalize_image_bytes,
read_local_bytes,
storage_key_from_url,
)
from services.watermark import apply_watermark
@@ -46,8 +55,20 @@ class ImageAssetService:
return read_local_bytes(path), mimetypes.guess_type(path.name)[0] or "image/jpeg"
return await download_bytes(url, referer=guess_referer(url))
async def save_original(self, asset: ProductAsset, data: bytes, content_type: str = "") -> str:
async def save_original(
self,
asset: ProductAsset,
data: bytes,
content_type: str = "",
max_bytes: int | None = None,
) -> str:
"""写入该素材唯一的无水印原图,并将 current 同步到原图。"""
if asset.type == "img":
normalized = normalize_image_bytes(data, max_bytes=max_bytes)
data = normalized.data
content_type = normalized.content_type
asset.width = normalized.width
asset.height = normalized.height
key = self._key(asset, "original", data, content_type)
url = await self.storage.save_bytes(data, key, content_type)
asset.original_url = url
@@ -60,7 +81,12 @@ class ImageAssetService:
async def save_original_from_url(self, asset: ProductAsset, url: str) -> str:
data, content_type = await self._read(url)
return await self.save_original(asset, data, content_type)
return await self.save_original(
asset,
data,
content_type,
max_bytes=get_settings().image_download_max_bytes,
)
async def ensure_original(self, asset: ProductAsset) -> tuple[bytes, str]:
"""为旧记录补齐原图字段;首次触碰旧素材时迁入新的商品/素材路径。"""
+43 -1
View File
@@ -26,6 +26,7 @@ from services.storage import get_storage
from services.suite_service import append_generated_asset
from services.tasks import TaskImage
from services.watermark import apply_watermark
from services.video_renderer import VideoRenderCancelled, render_slideshow
log = logging.getLogger("background.worker")
@@ -113,11 +114,52 @@ async def _process_watermark_item(claim: ClaimedItem) -> None:
await mark_item_failed(claim.item_id, str(exc))
async def _process_slideshow_video_item(claim: ClaimedItem) -> None:
"""一个视频任务只有一个子项;输入 URL 在提交时冻结,避免后续选择状态影响成片。"""
payload = claim.request_payload
sources = list(payload.get("sources") or [])
source_urls = [str(source.get("url") or "") for source in sources]
if not 2 <= len(source_urls) <= 5 or any(not url for url in source_urls):
await mark_item_failed(claim.item_id, "视频任务缺少 2–5 张可用图片")
return
try:
async def should_cancel() -> bool:
return not await job_can_write(claim.job_id)
rendered = await render_slideshow(source_urls, should_cancel=should_cancel)
if not await job_can_write(claim.job_id):
await mark_item_cancelled(claim.item_id)
return
key = f"products/{claim.product_id}/videos/{claim.job_id}/result.mp4"
result_url = await get_storage().save_bytes(rendered.data, key, "video/mp4")
async with get_session_factory()() as db:
from models import BackgroundJobItem
item = await db.get(BackgroundJobItem, uuid.UUID(claim.item_id))
if item is not None:
item.payload = {
**dict(item.payload),
"duration_seconds": rendered.duration_seconds,
"width": rendered.width,
"height": rendered.height,
"file_size_bytes": len(rendered.data),
}
await db.commit()
await mark_item_done(claim.item_id, result_url=result_url, source_url=None, watermark_config=None, generated_asset_id=None)
except VideoRenderCancelled:
await mark_item_cancelled(claim.item_id)
except Exception as exc: # noqa: BLE001
log.exception("后台轮播视频任务 %s/%s 执行失败", claim.job_id, claim.item_id)
await mark_item_failed(claim.item_id, str(exc))
async def process_item(claim: ClaimedItem) -> None:
"""推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。"""
if claim.job_kind == "watermark_batch":
await _process_watermark_item(claim)
return
if claim.job_kind == "slideshow_video":
await _process_slideshow_video_item(claim)
return
if claim.job_kind not in {"image_suite", "image_edit"}:
await mark_item_failed(claim.item_id, f"Worker 暂不支持任务类型: {claim.job_kind}")
return
@@ -201,7 +243,7 @@ async def run_worker(*, once: bool = False, idle_seconds: float = 0.75) -> None:
log.warning("恢复了 %d 个过期后台任务租约", recovered)
log.info("后台 Worker 已启动:%s", owner)
while True:
claim = await claim_next_item(owner)
claim = await claim_next_item(owner, lease_seconds=get_settings().background_job_lease_seconds)
if claim is None:
if once:
return
+37
View File
@@ -310,6 +310,21 @@ async def cancel_job(job_id: str) -> BackgroundJob | None:
return job
async def delete_job(job_id: str) -> bool:
"""硬删除一个后台任务及其子项;调用方须先处理对应的存储对象。"""
try:
parsed = uuid.UUID(job_id)
except ValueError:
return False
async with get_session_factory()() as db:
job = await db.get(BackgroundJob, parsed)
if job is None:
return False
await db.delete(job)
await db.commit()
return True
async def cancel_product_jobs(product_id: str) -> int:
"""硬删除商品前取消其未完成任务,Worker 随后只会丢弃结果而不会回写素材。"""
try:
@@ -468,3 +483,25 @@ def suite_view(job: BackgroundJob, items: list[BackgroundJobItem]) -> dict[str,
for item in items
],
}
def video_job_view(job: BackgroundJob, items: list[BackgroundJobItem]) -> dict[str, Any]:
"""轮播视频的轻量任务视图;仍复用 BackgroundJob 的恢复/取消/重试状态机。"""
payload = dict(job.request_payload or {})
item = items[0] if items else None
item_payload = dict(item.payload or {}) if item else {}
return {
"id": str(job.id), "kind": job.kind, "status": job.status,
"total": job.total, "completed": job.completed, "failed": job.failed,
"cancel_requested": job.cancel_requested, "preset": payload.get("preset", "wb_ozon"),
"source_asset_ids": [str(source.get("asset_id")) for source in payload.get("sources", [])],
"slide_seconds": payload.get("slide_seconds", 0),
"duration_seconds": item_payload.get("duration_seconds"),
"width": item_payload.get("width", 810), "height": item_payload.get("height", 1080),
"file_size_bytes": item_payload.get("file_size_bytes"),
"created_at": job.created_at, "started_at": job.started_at, "completed_at": job.completed_at,
"result": None if item is None else {
"item_id": str(item.id), "status": item.status, "url": item.result_url, "error": item.error,
},
"error": job.error,
}
+220 -24
View File
@@ -1,12 +1,20 @@
"""图片/文件存储抽象:本地文件系统(开发兜底)+ 七牛(生产)。"""
from __future__ import annotations
import asyncio
import io
import ipaddress
import mimetypes
import socket
import shutil
import uuid
import warnings
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urljoin, urlparse
import httpx
from PIL import Image, ImageOps, UnidentifiedImageError
from config import get_settings
@@ -26,6 +34,157 @@ class ResourceSizeLimitError(ValueError):
super().__init__(detail)
class UnsafeRemoteUrlError(ValueError):
"""远程资源 URL 指向回环、内网或其他保留网络,不能由服务端代请求。"""
class ResourceDownloadTimeoutError(TimeoutError):
"""远程资源下载超过连接、读取或总耗时限制。"""
class ImageValidationError(ValueError):
"""图片字节无法解码,或不符合格式与像素限制。"""
@dataclass(frozen=True)
class NormalizedImage:
"""经真实格式校验后的图片字节及可落库元数据。"""
data: bytes
content_type: str
width: int
height: int
image_format: str
orientation_normalized: bool
_IMAGE_CONTENT_TYPES = {
"JPEG": "image/jpeg",
"PNG": "image/png",
"WEBP": "image/webp",
"GIF": "image/gif",
"BMP": "image/bmp",
}
def normalize_image_bytes(
data: bytes,
max_bytes: int | None = None,
max_pixels: int | None = None,
) -> NormalizedImage:
"""按真实图片内容校验、纠正 EXIF 方向,并返回可信 MIME 与宽高。
不盲从 URL 后缀或客户端 Content-Type。为避免无意义的质量损耗,仅当 EXIF
方向不是默认值时才重编码;动画图不重编码,以免丢失帧。
"""
settings = get_settings()
byte_limit = max_bytes if max_bytes is not None else settings.image_upload_max_bytes
pixel_limit = max_pixels if max_pixels is not None else settings.image_max_pixels
if byte_limit <= 0 or pixel_limit <= 0:
raise ValueError("图片大小和像素上限必须大于 0")
if not data:
raise ImageValidationError("图片内容为空")
if len(data) > byte_limit:
raise ResourceSizeLimitError(byte_limit, len(data))
try:
# Pillow 的默认 bomb 保护不能替代项目自定义像素上限;把 warning 升级为错误。
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
with Image.open(io.BytesIO(data)) as image:
image_format = (image.format or "").upper()
if image_format not in _IMAGE_CONTENT_TYPES:
raise ImageValidationError(f"不支持的图片格式:{image_format or '未知'}")
width, height = image.size
if width <= 0 or height <= 0 or width * height > pixel_limit:
raise ImageValidationError(
f"图片像素超过上限(最多 {pixel_limit:,},当前 {width:,} × {height:,})"
)
# 强制实际解码,拒绝截断/伪造图片而不是只相信文件头。
image.load()
frame_count = getattr(image, "n_frames", 1)
orientation = image.getexif().get(274, 1) if frame_count == 1 else 1
normalized = orientation not in (None, 1)
output = data
if normalized:
corrected = ImageOps.exif_transpose(image)
width, height = corrected.size
if image_format == "JPEG" and corrected.mode not in {"RGB", "L"}:
corrected = corrected.convert("RGB")
buffer = io.BytesIO()
corrected.save(buffer, format=image_format)
output = buffer.getvalue()
except (UnidentifiedImageError, OSError, Image.DecompressionBombError) as exc:
raise ImageValidationError("文件不是可用的图片,或图片数据已损坏") from exc
except Image.DecompressionBombWarning as exc:
raise ImageValidationError("图片像素过大,已拒绝解码") from exc
if len(output) > byte_limit:
raise ResourceSizeLimitError(byte_limit, len(output))
return NormalizedImage(
data=output,
content_type=_IMAGE_CONTENT_TYPES[image_format],
width=width,
height=height,
image_format=image_format,
orientation_normalized=normalized,
)
def _is_public_ip(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""仅允许可公开路由的地址;IPv4-mapped IPv6 需按其 IPv4 实际地址判断。"""
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
return _is_public_ip(address.ipv4_mapped)
return address.is_global
async def validate_remote_url(url: str) -> None:
"""校验服务端允许代请求的公网 HTTP(S) URL。
该校验用于代理、采集转存、AI 参考图和导出。项目自己的 `/media/` 文件
在调用方会先直读磁盘;其他回环或私网 URL 一律不能通过网络访问。
"""
try:
parsed = urlparse(url)
port = parsed.port
except ValueError as exc:
raise UnsafeRemoteUrlError("图片地址端口无效") from exc
if parsed.scheme.lower() not in {"http", "https"}:
raise UnsafeRemoteUrlError("仅允许 http/https 图片地址")
if not parsed.hostname:
raise UnsafeRemoteUrlError("图片地址缺少主机名")
if parsed.username is not None or parsed.password is not None:
raise UnsafeRemoteUrlError("图片地址不能包含用户名或密码")
host = parsed.hostname.rstrip(".")
if host.lower() in {"localhost", "localhost.localdomain"}:
raise UnsafeRemoteUrlError("不允许请求 localhost 或本机网络地址")
try:
addresses = {ipaddress.ip_address(host)}
except ValueError:
try:
infos = await asyncio.get_running_loop().getaddrinfo(
host,
port or (443 if parsed.scheme.lower() == "https" else 80),
type=socket.SOCK_STREAM,
)
except socket.gaierror as exc:
raise UnsafeRemoteUrlError("图片地址域名无法解析") from exc
addresses = {ipaddress.ip_address(info[4][0]) for info in infos}
if not addresses:
raise UnsafeRemoteUrlError("图片地址域名未解析到有效地址")
private_addresses = [str(address) for address in addresses if not _is_public_ip(address)]
if private_addresses:
raise UnsafeRemoteUrlError(
f"不允许请求非公网地址({', '.join(sorted(private_addresses))})"
)
def _ext_from_content_type(content_type: str) -> str:
ctype = (content_type or "").split(";")[0].strip().lower()
mapping = {
@@ -48,41 +207,78 @@ def image_extension(data: bytes, content_type: str = "") -> str:
return ".webp"
if data[:3] == b"GIF":
return ".gif"
if data[:2] == b"\xff\xd8":
return ".jpg"
if data[:2] == b"BM":
return ".bmp"
return _ext_from_content_type(content_type)
async def download_bytes(
url: str,
referer: str | None = None,
timeout: float = 60.0,
timeout: float | None = None,
max_bytes: int | None = None,
) -> tuple[bytes, str]:
"""流式下载远程图片并限制体积。返回 (bytes, content_type)。"""
limit = max_bytes or get_settings().image_download_max_bytes
"""安全地流式下载公网资源,并限制地址、重定向、体积和耗时。"""
settings = get_settings()
limit = max_bytes or settings.image_download_max_bytes
if limit <= 0:
raise ValueError("图片下载大小上限必须大于 0")
headers = {"Referer": referer} if referer else {}
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
async with client.stream("GET", url, headers=headers) as resp:
resp.raise_for_status()
length = resp.headers.get("content-length")
if length:
try:
declared_size = int(length)
except ValueError:
declared_size = None
if declared_size is not None and declared_size > limit:
raise ResourceSizeLimitError(limit, declared_size)
total_timeout = timeout if timeout is not None else settings.image_download_total_timeout
if total_timeout <= 0:
raise ValueError("图片下载总超时必须大于 0")
if settings.image_download_connect_timeout <= 0 or settings.image_download_read_timeout <= 0:
raise ValueError("图片下载连接和读取超时必须大于 0")
if settings.image_download_max_redirects < 0:
raise ValueError("图片下载重定向上限不能小于 0")
chunks: list[bytes] = []
total = 0
async for chunk in resp.aiter_bytes():
total += len(chunk)
if total > limit:
raise ResourceSizeLimitError(limit, total)
chunks.append(chunk)
ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip()
return b"".join(chunks), ctype
headers = {"Referer": referer} if referer else {}
http_timeout = httpx.Timeout(
connect=settings.image_download_connect_timeout,
read=settings.image_download_read_timeout,
write=settings.image_download_read_timeout,
pool=settings.image_download_connect_timeout,
)
current_url = url
try:
async with asyncio.timeout(total_timeout):
async with httpx.AsyncClient(timeout=http_timeout, follow_redirects=False) as client:
for redirect_count in range(settings.image_download_max_redirects + 1):
await validate_remote_url(current_url)
async with client.stream("GET", current_url, headers=headers) as resp:
if resp.is_redirect:
location = resp.headers.get("location")
if not location:
raise httpx.RemoteProtocolError("重定向响应缺少 Location")
if redirect_count >= settings.image_download_max_redirects:
raise httpx.TooManyRedirects("图片重定向次数超过上限")
current_url = urljoin(str(resp.url), location)
continue
resp.raise_for_status()
length = resp.headers.get("content-length")
if length:
try:
declared_size = int(length)
except ValueError:
declared_size = None
if declared_size is not None and declared_size > limit:
raise ResourceSizeLimitError(limit, declared_size)
chunks: list[bytes] = []
total = 0
async for chunk in resp.aiter_bytes():
total += len(chunk)
if total > limit:
raise ResourceSizeLimitError(limit, total)
chunks.append(chunk)
ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip()
return b"".join(chunks), ctype
except (TimeoutError, httpx.TimeoutException) as exc:
raise ResourceDownloadTimeoutError(f"图片下载超时(最多 {total_timeout:g} 秒)") from exc
raise RuntimeError("图片下载未返回有效响应")
def read_local_bytes(path: Path, max_bytes: int | None = None) -> bytes:
+187
View File
@@ -0,0 +1,187 @@
"""用 FFmpeg 合成平台兼容的无音轨商品轮播视频。"""
from __future__ import annotations
import asyncio
import json
import os
import tempfile
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pathlib import Path
from config import get_settings
from services.storage import (
ResourceSizeLimitError,
download_bytes,
image_extension,
local_path,
normalize_image_bytes,
read_local_bytes,
)
class VideoRenderError(RuntimeError):
"""FFmpeg 无法生成符合预设的商品视频。"""
class VideoRenderCancelled(VideoRenderError):
"""任务在 FFmpeg 运行期间被用户取消。"""
@dataclass(frozen=True)
class RenderedVideo:
data: bytes
duration_seconds: float
width: int
height: int
def slide_seconds_for(count: int) -> float:
"""按图片数量维持约 9–13 秒成片,并确保两图时也不会过短。"""
by_count = {2: 4.5, 3: 3.5, 4: 3.0, 5: 2.8}
try:
return by_count[count]
except KeyError as exc:
raise VideoRenderError("轮播视频只支持 2–5 张图片") from exc
async def _read_image(url: str) -> tuple[bytes, str]:
path = local_path(url)
if path is not None:
return read_local_bytes(path), ""
return await download_bytes(url, max_bytes=get_settings().image_download_max_bytes)
async def _run_process(
args: list[str],
*,
should_cancel: Callable[[], Awaitable[bool]],
timeout_seconds: int,
) -> None:
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
try:
waited = 0.0
while process.returncode is None:
if await should_cancel():
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=5)
except TimeoutError:
process.kill()
await process.wait()
raise VideoRenderCancelled("视频任务已取消")
await asyncio.sleep(0.25)
waited += 0.25
if waited > timeout_seconds:
process.kill()
await process.wait()
raise VideoRenderError(f"视频渲染超过 {timeout_seconds} 秒,请减少图片后重试")
stderr = (await process.stderr.read()).decode("utf-8", errors="replace") if process.stderr else ""
if process.returncode != 0:
raise VideoRenderError(f"FFmpeg 渲染失败:{stderr[-800:] or '未知错误'}")
finally:
if process.returncode is None:
process.kill()
await process.wait()
async def _probe(path: Path) -> dict:
settings = get_settings()
process = await asyncio.create_subprocess_exec(
settings.video_ffprobe_path,
"-v", "error", "-show_entries", "format=duration,size:stream=codec_name,width,height,pix_fmt",
"-of", "json", str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise VideoRenderError(f"无法校验视频输出:{stderr.decode('utf-8', errors='replace')[-400:]}")
try:
return json.loads(stdout)
except json.JSONDecodeError as exc:
raise VideoRenderError("FFprobe 未返回可解析的视频信息") from exc
async def render_slideshow(
source_urls: list[str],
*,
should_cancel: Callable[[], Awaitable[bool]],
) -> RenderedVideo:
"""将已入库图片缩放留白后串为 MP4,严禁接收调用方任意命令参数。"""
settings = get_settings()
if not settings.video_ffmpeg_path or not settings.video_ffprobe_path:
raise VideoRenderError("未配置 FFmpeg 或 FFprobe 可执行路径")
count = len(source_urls)
seconds = slide_seconds_for(count)
transition = settings.video_transition_seconds
with tempfile.TemporaryDirectory(prefix="osk-video-") as temp_dir:
workdir = Path(temp_dir)
inputs: list[Path] = []
for index, url in enumerate(source_urls):
if await should_cancel():
raise VideoRenderCancelled("视频任务已取消")
data, content_type = await _read_image(url)
normalized = normalize_image_bytes(data, max_bytes=settings.image_download_max_bytes)
suffix = image_extension(normalized.data, content_type or normalized.content_type)
image_path = workdir / f"input-{index}{suffix}"
image_path.write_bytes(normalized.data)
inputs.append(image_path)
output_path = workdir / "result.mp4"
args = [settings.video_ffmpeg_path, "-y", "-hide_banner", "-loglevel", "error"]
for image_path in inputs:
args.extend(["-loop", "1", "-framerate", str(settings.video_fps), "-t", str(seconds), "-i", str(image_path)])
filters: list[str] = []
for index in range(count):
filters.append(
f"[{index}:v]scale={settings.video_width}:{settings.video_height}:"
"force_original_aspect_ratio=decrease,"
f"pad={settings.video_width}:{settings.video_height}:(ow-iw)/2:(oh-ih):color=white,"
"setsar=1,format=yuv420p"
f"[slide{index}]"
)
previous = "slide0"
accumulated = seconds
for index in range(1, count):
output = f"fade{index}"
filters.append(
f"[{previous}][slide{index}]xfade=transition=fade:duration={transition}:"
f"offset={accumulated - transition:.3f}[{output}]"
)
previous = output
accumulated += seconds - transition
filters.append(f"[{previous}]fps={settings.video_fps},format=yuv420p[video]")
args.extend([
"-filter_complex", ";".join(filters), "-map", "[video]",
"-an", "-c:v", "libx264", "-profile:v", "high", "-pix_fmt", "yuv420p",
"-preset", "medium", "-crf", str(settings.video_crf),
"-maxrate", settings.video_max_rate, "-bufsize", settings.video_buffer_size,
"-movflags", "+faststart", str(output_path),
])
await _run_process(args, should_cancel=should_cancel, timeout_seconds=settings.video_render_timeout_seconds)
if not output_path.is_file():
raise VideoRenderError("FFmpeg 未生成视频文件")
file_size = output_path.stat().st_size
if file_size > settings.video_max_bytes:
raise ResourceSizeLimitError(settings.video_max_bytes, file_size)
probe = await _probe(output_path)
stream = next((item for item in probe.get("streams", []) if item.get("codec_name") == "h264"), None)
if stream is None:
raise VideoRenderError("输出视频不是 H.264 编码")
width, height = int(stream.get("width", 0)), int(stream.get("height", 0))
duration = float((probe.get("format") or {}).get("duration", 0))
if (width, height) != (settings.video_width, settings.video_height):
raise VideoRenderError(f"输出分辨率异常:{width} × {height}")
if not 0 < duration <= settings.video_max_duration_seconds:
raise VideoRenderError(f"输出视频时长异常:{duration:.1f} 秒")
data = output_path.read_bytes()
if len(data) != file_size:
raise VideoRenderError("读取视频输出失败")
return RenderedVideo(data=data, duration_seconds=duration, width=width, height=height)