feat(ai-studio): add remote image loading helpers

This commit is contained in:
chengma
2026-07-11 12:31:12 +08:00
parent 10a28c1e77
commit 1267e0a727
4 changed files with 632 additions and 3 deletions
+17
View File
@@ -553,6 +553,23 @@ def mark_asset_status(asset_id, status, path=None, conn=None):
return get_asset(asset_id, conn=database)
def update_asset_local_path(asset_id, local_path, status=ASSET_STATUS_AVAILABLE, path=None, conn=None):
if str(status) not in ASSET_STATUSES:
raise db.DbError("AI工场资产状态必须是 available 或 missing")
abs_local_path = os.path.abspath(local_path) if local_path else None
with _connection(conn, path) as database:
with database:
database.execute(
"""
UPDATE image_studio_assets
SET local_path = ?, status = ?, updated_at = ?
WHERE id = ?
""",
(abs_local_path, str(status), _now(), int(asset_id)),
)
return get_asset(asset_id, conn=database)
def create_job(
project_id,
*,
+355
View File
@@ -0,0 +1,355 @@
"""Remote image helpers for the AI image studio."""
from __future__ import annotations
import hashlib
import io
import ipaddress
import os
import socket
import threading
import urllib.parse
import uuid
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import Callable, Optional
import requests
from . import appconfig, image_studio
THUMBNAIL_MAX_BYTES = 3 * 1024 * 1024
ORIGINAL_MAX_BYTES = 12 * 1024 * 1024
DEFAULT_CONNECT_TIMEOUT_SECONDS = 5
DEFAULT_READ_TIMEOUT_SECONDS = 30
DEFAULT_THUMBNAIL_SIZE = 220
DEFAULT_THUMBNAIL_WORKERS = 4
class ImageStudioImageError(RuntimeError):
"""Raised when a remote image cannot be safely loaded or saved."""
@dataclass(frozen=True)
class RemoteImage:
url: str
content: bytes
content_type: str
final_url: str
redirected: bool
@dataclass(frozen=True)
class ThumbnailResult:
key: str
url: str
image_bytes: bytes
width: int
height: int
from_cache: bool = False
def _session():
session = requests.Session()
session.trust_env = False
return session
def _assert_public_http_url(url):
parts = urllib.parse.urlsplit(str(url or ""))
if parts.scheme not in {"http", "https"}:
raise ImageStudioImageError("远程图片地址只允许 http/https")
host = parts.hostname
if not host:
raise ImageStudioImageError("远程图片地址缺少域名")
if _is_local_hostname(host):
raise ImageStudioImageError("远程图片地址不能指向本机或内网")
try:
_assert_public_ip(host)
return
except ValueError:
pass
try:
addresses = socket.getaddrinfo(
host,
parts.port or (443 if parts.scheme == "https" else 80),
type=socket.SOCK_STREAM,
)
except OSError as exc:
raise ImageStudioImageError(f"远程图片地址无法解析:{exc}") from exc
if not addresses:
raise ImageStudioImageError("远程图片地址无法解析")
for address in addresses:
_assert_public_ip(address[4][0])
def _is_local_hostname(host):
lowered = str(host or "").strip().lower().rstrip(".")
return lowered in {"localhost"} or lowered.endswith(".localhost") or lowered.endswith(".local")
def _assert_public_ip(value):
ip = ipaddress.ip_address(value)
if (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
):
raise ImageStudioImageError("远程图片地址不能指向本机或内网")
def _safe_headers(referer=None):
headers = {
"User-Agent": "Mozilla/5.0",
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
}
if referer:
headers["Referer"] = str(referer)
return headers
def _validate_content_type(value):
content_type = str(value or "").split(";", 1)[0].strip().lower()
if content_type and not content_type.startswith("image/"):
raise ImageStudioImageError("远程地址返回的不是图片内容")
return content_type
def download_remote_image(
url,
*,
referer=None,
max_bytes=ORIGINAL_MAX_BYTES,
timeout=(DEFAULT_CONNECT_TIMEOUT_SECONDS, DEFAULT_READ_TIMEOUT_SECONDS),
session=None,
) -> RemoteImage:
"""Download remote image bytes with SSRF, timeout and size guards."""
url = str(url or "").strip()
_assert_public_http_url(url)
client = session or _session()
try:
response = client.get(
url,
stream=True,
timeout=timeout,
headers=_safe_headers(referer),
)
except requests.exceptions.RequestException as exc:
raise ImageStudioImageError(f"远程图片下载失败:{exc}") from exc
status = getattr(response, "status_code", 200)
if status >= 400:
raise ImageStudioImageError(f"远程图片下载失败:HTTP {status}")
final_url = str(getattr(response, "url", "") or url)
_assert_public_http_url(final_url)
content_type = _validate_content_type(getattr(response, "headers", {}).get("Content-Type", ""))
content_length = getattr(response, "headers", {}).get("Content-Length")
if content_length:
try:
if int(content_length) > int(max_bytes):
raise ImageStudioImageError("远程图片超过大小上限")
except ValueError:
pass
chunks = []
total = 0
iterator = response.iter_content(chunk_size=65536) if hasattr(response, "iter_content") else [response.content]
for chunk in iterator:
if not chunk:
continue
total += len(chunk)
if total > int(max_bytes):
raise ImageStudioImageError("远程图片超过大小上限")
chunks.append(chunk)
content = b"".join(chunks)
_image_info(content)
return RemoteImage(
url=url,
content=content,
content_type=content_type,
final_url=final_url,
redirected=final_url != url,
)
def _image_info(image_bytes):
try:
from PIL import Image
with Image.open(io.BytesIO(image_bytes)) as image:
image.verify()
with Image.open(io.BytesIO(image_bytes)) as image:
return {
"format": str(image.format or "JPEG").upper(),
"width": int(image.width),
"height": int(image.height),
}
except Exception as exc:
raise ImageStudioImageError(f"图片解码失败:{exc}") from exc
def _thumbnail_bytes(image_bytes, max_size=DEFAULT_THUMBNAIL_SIZE):
try:
from PIL import Image
with Image.open(io.BytesIO(image_bytes)) as image:
image.thumbnail((int(max_size), int(max_size)))
output = io.BytesIO()
image.convert("RGBA").save(output, format="PNG")
return output.getvalue(), image.width, image.height
except Exception as exc:
raise ImageStudioImageError(f"缩略图解码失败:{exc}") from exc
def load_thumbnail(url, *, key=None, max_size=DEFAULT_THUMBNAIL_SIZE, referer=None, session=None):
remote = download_remote_image(
url,
referer=referer,
max_bytes=THUMBNAIL_MAX_BYTES,
session=session,
)
image_bytes, width, height = _thumbnail_bytes(remote.content, max_size=max_size)
return ThumbnailResult(
key=str(key or url),
url=remote.url,
image_bytes=image_bytes,
width=width,
height=height,
from_cache=False,
)
class ThumbnailLoader:
"""Small thread-pool thumbnail loader with memory cache and cooperative cancel."""
def __init__(self, max_workers=DEFAULT_THUMBNAIL_WORKERS, loader: Optional[Callable] = None):
self._executor = ThreadPoolExecutor(max_workers=max(1, int(max_workers or 1)))
self._loader = loader or load_thumbnail
self._cache = {}
self._cancelled = set()
self._closed = False
self._lock = threading.Lock()
def submit(self, key, url, on_success=None, on_error=None):
key = str(key)
url = str(url or "").strip()
with self._lock:
if self._closed:
raise ImageStudioImageError("缩略图加载器已关闭")
cached = self._cache.get(url)
if cached is not None:
result = ThumbnailResult(key, url, cached.image_bytes, cached.width, cached.height, from_cache=True)
if on_success is not None:
on_success(result)
return None
self._cancelled.discard(key)
future = self._executor.submit(self._loader, url, key=key)
def done_callback(done):
try:
result = done.result()
with self._lock:
if key in self._cancelled or self._closed:
return
self._cache[url] = result
if on_success is not None:
on_success(result)
except Exception as exc:
with self._lock:
if key in self._cancelled or self._closed:
return
if on_error is not None:
on_error(key, exc)
future.add_done_callback(done_callback)
return future
def cancel(self, key=None):
with self._lock:
if key is None:
self._cancelled.add("*")
self._closed = True
else:
self._cancelled.add(str(key))
def close(self):
with self._lock:
self._closed = True
self._executor.shutdown(wait=False, cancel_futures=True)
def _extension_for_format(format_name):
normalized = str(format_name or "").strip().upper()
if normalized in {"JPEG", "JPG"}:
return ".jpg"
if normalized == "PNG":
return ".png"
if normalized == "WEBP":
return ".webp"
return ".img"
def _original_file_path(project, asset, image_root):
dirs = image_studio.project_image_dirs(image_root, project)
digest = hashlib.sha1(str(asset.remote_url or asset.id).encode("utf-8")).hexdigest()[:10]
stem = "original_%02d_%s" % (max(1, int(asset.source_order or 1)), digest)
return dirs["originals"], stem
def _existing_local_asset(asset):
local_path = str(asset.local_path or "").strip()
if not local_path or not os.path.isfile(local_path):
return None
try:
with open(local_path, "rb") as fh:
_image_info(fh.read())
return asset
except ImageStudioImageError:
return None
def download_original_asset(asset_id, *, path=None, config=None, image_root=None, session=None):
"""Download one Shopee original image into originals/ and mark its asset available."""
cfg = appconfig.load_config() if config is None else config
database_path = path or appconfig.db_path(cfg)
asset = image_studio.get_asset(asset_id, path=database_path)
if asset is None:
raise ImageStudioImageError("原图资产不存在")
if not asset.remote_url:
raise ImageStudioImageError("原图资产缺少远程 URL")
existing = _existing_local_asset(asset)
if existing is not None:
return existing
project = image_studio.get_project(asset.project_id, path=database_path)
if project is None:
raise ImageStudioImageError("原图资产所属项目不存在")
remote = download_remote_image(asset.remote_url, max_bytes=ORIGINAL_MAX_BYTES, session=session)
info = _image_info(remote.content)
directory, stem = _original_file_path(project, asset, image_root or appconfig.image_dir(cfg))
os.makedirs(directory, exist_ok=True)
final_path = os.path.join(directory, stem + _extension_for_format(info["format"]))
temp_path = final_path + ".tmp-" + uuid.uuid4().hex
try:
with open(temp_path, "wb") as fh:
fh.write(remote.content)
with open(temp_path, "rb") as fh:
_image_info(fh.read())
os.replace(temp_path, final_path)
except Exception as exc:
try:
if os.path.exists(temp_path):
os.remove(temp_path)
finally:
if isinstance(exc, ImageStudioImageError):
raise
raise ImageStudioImageError(f"保存原图失败:{exc}") from exc
return image_studio.update_asset_local_path(
asset.id,
final_path,
status=image_studio.ASSET_STATUS_AVAILABLE,
path=database_path,
)
+9 -3
View File
@@ -2,8 +2,8 @@
id: T-588
title: AI工场远程缩略图与蝦皮原图进入照片池
phase: 7
deps: [T-587, T-546]
status: TODO
deps: [T-587, T-548]
status: DONE
created: 2026-07-11
---
@@ -35,4 +35,10 @@ created: 2026-07-11
## 执行记录
(完成后记录 CDN 实探、缓存和下载验证。)
- 2026-07-11:完成 AI工场远程图片底层能力。
- 修正任务依赖:原 `T-546` 任务文件不存在,实际复用已完成的 T-548 图片下载安全/超时口径,frontmatter 改为 `deps: [T-587, T-548]`。
- 新增 `app/image_studio_images.py`:提供远程图片安全下载(http/https、公网地址、无系统代理、连接/读取超时、大小上限、Content-Type 与最终重定向 URL 校验、PIL 解码校验)、内存缩略图生成、线程池 `ThumbnailLoader`(缓存、取消、失败回调)和单张原图下载落 `originals/`。
- `download_original_asset()` 只在用户单击时下载对应原图,写临时文件、解码校验后 `os.replace()` 原子落盘,并通过 `image_studio.update_asset_local_path()` 回写同一 remote asset;重复单击已有可解码本地文件时直接返回,不重复请求。
- 新增 `tests/test_image_studio_images.py` 覆盖缩略图不落盘、缓存命中、取消、原图落盘幂等、坏图片不留下半文件/可用 asset、非图片、超限、内网 URL 和重定向到内网 URL。
- 验证:在只套用 T-588 diff 的 clean worktree 中运行 `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(355 tests)和 `git diff --check`,全部通过。
- CDN 真实实探未执行:本轮没有指定可用的已登录测试账号/商品现场;代码已记录可观测字段 `content_type/final_url/redirected` 并覆盖回退与边界,后续接入 T-591 GUI 前建议用测试账号补一条真实 Shopee CDN 记录。
+251
View File
@@ -0,0 +1,251 @@
import io
import os
import socket
import sys
import threading
import time
import unittest
from types import SimpleNamespace
from unittest import mock
sys.path.insert(0, os.path.dirname(__file__))
from _helpers import TempDirMixin
from app import db, image_studio, image_studio_images
class _Response:
def __init__(
self,
content=b"",
status_code=200,
headers=None,
url="https://cdn.example.com/image.png",
chunk_size=None,
):
self.content = content
self.status_code = status_code
self.headers = dict(headers or {})
self.url = url
self.chunk_size = chunk_size
def iter_content(self, chunk_size=65536):
if self.chunk_size:
for index in range(0, len(self.content), self.chunk_size):
yield self.content[index:index + self.chunk_size]
return
yield self.content
class ImageStudioImageTests(TempDirMixin, unittest.TestCase):
def _png_bytes(self, size=(20, 16), color=(80, 120, 200, 255)):
from PIL import Image
output = io.BytesIO()
Image.new("RGBA", size, color).save(output, format="PNG")
return output.getvalue()
def _public_dns(self):
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443))]
def _project_and_asset(self, temp_dir, remote_url="https://cdn.example.com/source.png"):
db_path = os.path.join(temp_dir, "cmshopee.db")
db.init_db(db_path)
project = image_studio.create_or_get_project(
account_alias="alias",
account_slug="alias_slug",
item_id="51100639510",
path=db_path,
)
asset = image_studio.sync_original_asset_urls(
project.id,
[{"index": 1, "src": remote_url}],
path=db_path,
)[0]
return db_path, project, asset
def test_download_remote_image_rejects_private_non_image_overlimit_and_private_redirect(self):
with self.assertRaisesRegex(image_studio_images.ImageStudioImageError, "http/https|本机或内网"):
image_studio_images.download_remote_image("http://127.0.0.1/a.png")
non_image_session = SimpleNamespace(
get=lambda *args, **kwargs: _Response(
content=b"hello",
headers={"Content-Type": "text/html"},
)
)
with mock.patch("app.image_studio_images.socket.getaddrinfo", return_value=self._public_dns()):
with self.assertRaisesRegex(image_studio_images.ImageStudioImageError, "不是图片"):
image_studio_images.download_remote_image(
"https://cdn.example.com/a.png",
session=non_image_session,
)
oversized_session = SimpleNamespace(
get=lambda *args, **kwargs: _Response(
content=b"x",
headers={"Content-Type": "image/png", "Content-Length": "999"},
)
)
with mock.patch("app.image_studio_images.socket.getaddrinfo", return_value=self._public_dns()):
with self.assertRaisesRegex(image_studio_images.ImageStudioImageError, "大小上限"):
image_studio_images.download_remote_image(
"https://cdn.example.com/a.png",
session=oversized_session,
max_bytes=10,
)
redirect_session = SimpleNamespace(
get=lambda *args, **kwargs: _Response(
content=self._png_bytes(),
headers={"Content-Type": "image/png"},
url="http://127.0.0.1/private.png",
)
)
with mock.patch("app.image_studio_images.socket.getaddrinfo", return_value=self._public_dns()):
with self.assertRaisesRegex(image_studio_images.ImageStudioImageError, "本机或内网"):
image_studio_images.download_remote_image(
"https://cdn.example.com/a.png",
session=redirect_session,
)
def test_load_thumbnail_decodes_image_without_writing_file(self):
png = self._png_bytes(size=(640, 480))
session = SimpleNamespace(
get=lambda *args, **kwargs: _Response(
content=png,
headers={"Content-Type": "image/png"},
)
)
with mock.patch("app.image_studio_images.socket.getaddrinfo", return_value=self._public_dns()):
result = image_studio_images.load_thumbnail(
"https://cdn.example.com/a.png",
key="thumb-1",
max_size=120,
session=session,
)
self.assertEqual("thumb-1", result.key)
self.assertLessEqual(result.width, 120)
self.assertLessEqual(result.height, 120)
self.assertTrue(result.image_bytes.startswith(b"\x89PNG"))
def test_thumbnail_loader_uses_memory_cache_and_can_cancel(self):
calls = []
successes = []
errors = []
def fake_loader(url, key=None):
calls.append((key, url))
return image_studio_images.ThumbnailResult(
key=str(key),
url=url,
image_bytes=b"png",
width=10,
height=10,
)
loader = image_studio_images.ThumbnailLoader(max_workers=1, loader=fake_loader)
try:
future = loader.submit("a", "https://cdn.example.com/a.png", successes.append, errors.append)
future.result(timeout=2)
for _ in range(20):
if successes:
break
time.sleep(0.01)
loader.submit("b", "https://cdn.example.com/a.png", successes.append, errors.append)
self.assertEqual([("a", "https://cdn.example.com/a.png")], calls)
self.assertEqual(2, len(successes))
self.assertFalse(successes[0].from_cache)
self.assertTrue(successes[1].from_cache)
self.assertEqual([], errors)
release = threading.Event()
def slow_loader(url, key=None):
release.wait(1)
return image_studio_images.ThumbnailResult(
key=str(key),
url=url,
image_bytes=b"png",
width=10,
height=10,
)
loader._loader = slow_loader
future = loader.submit("c", "https://cdn.example.com/c.png", successes.append, errors.append)
loader.cancel("c")
release.set()
future.result(timeout=2)
time.sleep(0.05)
self.assertEqual(2, len(successes))
finally:
loader.close()
def test_download_original_asset_writes_atomically_and_is_idempotent(self):
with self.make_temp_dir() as temp_dir:
db_path, _, asset = self._project_and_asset(temp_dir)
png = self._png_bytes()
session = mock.Mock()
session.get.return_value = _Response(
content=png,
headers={"Content-Type": "image/png"},
)
with mock.patch("app.image_studio_images.socket.getaddrinfo", return_value=self._public_dns()):
downloaded = image_studio_images.download_original_asset(
asset.id,
path=db_path,
config={"data_dir": temp_dir, "image_dir": os.path.join(temp_dir, "images")},
session=session,
)
second = image_studio_images.download_original_asset(
asset.id,
path=db_path,
config={"data_dir": temp_dir, "image_dir": os.path.join(temp_dir, "images")},
session=mock.Mock(),
)
self.assertEqual(downloaded.id, second.id)
self.assertEqual(downloaded.local_path, second.local_path)
self.assertTrue(os.path.isfile(downloaded.local_path))
self.assertTrue(downloaded.local_path.endswith(".png"))
self.assertEqual(image_studio.ASSET_STATUS_AVAILABLE, downloaded.status)
session.get.assert_called_once()
originals = os.path.dirname(downloaded.local_path)
self.assertFalse([name for name in os.listdir(originals) if ".tmp-" in name])
self.assert_removed(temp_dir)
def test_download_original_asset_bad_image_leaves_asset_without_local_path(self):
with self.make_temp_dir() as temp_dir:
db_path, project, asset = self._project_and_asset(temp_dir)
session = SimpleNamespace(
get=lambda *args, **kwargs: _Response(
content=b"not an image",
headers={"Content-Type": "image/png"},
)
)
with mock.patch("app.image_studio_images.socket.getaddrinfo", return_value=self._public_dns()):
with self.assertRaisesRegex(image_studio_images.ImageStudioImageError, "解码失败"):
image_studio_images.download_original_asset(
asset.id,
path=db_path,
config={"data_dir": temp_dir, "image_dir": os.path.join(temp_dir, "images")},
session=session,
)
refreshed = image_studio.get_asset(asset.id, path=db_path)
self.assertIsNone(refreshed.local_path)
dirs = image_studio.project_image_dirs(os.path.join(temp_dir, "images"), project)
self.assertFalse(os.path.exists(dirs["originals"]))
self.assert_removed(temp_dir)
if __name__ == "__main__":
unittest.main()