From 0fa7b6bb8f8d249c234f8bb269f631c6f0c49cab Mon Sep 17 00:00:00 2001 From: joey-home Date: Mon, 28 Sep 2026 22:39:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=20400=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/export.py | 23 ++++++++++++++++++++-- server/tests/test_export.py | 19 ++++++++++++++++++ studio/src/pages/trial/TrialSuitePanel.tsx | 6 ++++++ studio/src/services/api.ts | 12 ++++++++++- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/server/api/export.py b/server/api/export.py index c5ee967..8668de2 100644 --- a/server/api/export.py +++ b/server/api/export.py @@ -82,6 +82,23 @@ def _is_image(url: str, ctype: str) -> bool: return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE)) +def _image_content_type(data: bytes, url: str, ctype: str) -> str | None: + """存储对象无扩展名且 MIME 为 octet-stream 时,按图片文件头识别格式。""" + if ctype.split(";")[0].strip().lower().startswith("image/"): + return ctype + if data.startswith(b"\xff\xd8\xff"): + return "image/jpeg" + if data.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png" + if data.startswith((b"GIF87a", b"GIF89a")): + return "image/gif" + if data.startswith(b"RIFF") and data[8:12] == b"WEBP": + return "image/webp" + if data.startswith(b"BM"): + return "image/bmp" + return ctype if _is_image(url, ctype) else None + + def _download_disposition(filename: str) -> str: """生成兼容中文文件名的下载响应头。 @@ -138,7 +155,8 @@ async def export_images(req: ExportImagesRequest): raise HTTPException(status_code=504, detail=str(exc)) from exc except Exception: # noqa: BLE001 continue # 单张下载失败不中断整包 - if not _is_image(img.url, ctype): + ctype = _image_content_type(data, img.url, ctype) + if ctype is None: continue total_source_bytes += len(data) if total_source_bytes > settings.export_zip_max_bytes: @@ -192,7 +210,8 @@ async def export_image(img: ExportImageItem): raise HTTPException(status_code=504, detail=str(exc)) from exc except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=422, detail="图片下载失败") from exc - if not _is_image(img.url, ctype): + ctype = _image_content_type(data, img.url, ctype) + if ctype is None: raise HTTPException(status_code=400, detail="该地址不是图片") ext = _ext(img.url, ctype) diff --git a/server/tests/test_export.py b/server/tests/test_export.py index fa41cb5..137740b 100644 --- a/server/tests/test_export.py +++ b/server/tests/test_export.py @@ -8,6 +8,8 @@ import zipfile from pathlib import Path from unittest.mock import AsyncMock, patch +from fastapi import HTTPException + SERVER_DIR = Path(__file__).resolve().parents[1] if str(SERVER_DIR) not in sys.path: sys.path.insert(0, str(SERVER_DIR)) @@ -16,6 +18,23 @@ from api.export import ExportImageItem, ExportImagesRequest, export_image, expor class ExportImageTests(unittest.IsolatedAsyncioTestCase): + async def test_extensionless_image_with_generic_mime_can_be_downloaded(self) -> None: + image = ExportImageItem(url="https://example.com/assets/opaque-key", key="upload") + png = b"\x89PNG\r\n\x1a\n" + b"image bytes" + with patch("api.export._download", new=AsyncMock(return_value=(png, "application/octet-stream"))): + response = await export_image(image) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.media_type, "image/png") + self.assertIn('filename="upload.png"', response.headers["content-disposition"]) + + async def test_extensionless_non_image_is_rejected(self) -> None: + image = ExportImageItem(url="https://example.com/assets/opaque-key") + with patch("api.export._download", new=AsyncMock(return_value=(b"not an image", "application/octet-stream"))): + with self.assertRaises(HTTPException) as raised: + await export_image(image) + self.assertEqual(raised.exception.status_code, 400) + async def test_chinese_filename_uses_rfc5987_header(self) -> None: image = ExportImageItem( url="https://example.com/image.png", diff --git a/studio/src/pages/trial/TrialSuitePanel.tsx b/studio/src/pages/trial/TrialSuitePanel.tsx index 8e175b4..260d2aa 100644 --- a/studio/src/pages/trial/TrialSuitePanel.tsx +++ b/studio/src/pages/trial/TrialSuitePanel.tsx @@ -803,6 +803,12 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr }); const name = cleanFilename(img.name || img.key) || '生图结果'; const suffix = (() => { + const mimeExt: Record = { + 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', + 'image/gif': '.gif', 'image/bmp': '.bmp', + }; + const fromMime = mimeExt[blob.type.split(';')[0].toLowerCase()]; + if (fromMime) return fromMime; try { return new URL(img.url, window.location.origin).pathname.match(/\.(jpe?g|png|webp|gif|bmp)$/i)?.[0] ?? '.jpg'; } catch { diff --git a/studio/src/services/api.ts b/studio/src/services/api.ts index 6992714..2d48372 100644 --- a/studio/src/services/api.ts +++ b/studio/src/services/api.ts @@ -11,7 +11,17 @@ const apiClient: AxiosInstance = axios.create({ apiClient.interceptors.response.use( (response) => response, - (error) => { + async (error) => { + // responseType: 'blob' 也会把 JSON 错误体包装成 Blob,先还原 detail。 + const data = error.response?.data; + const contentType = error.response?.headers?.['content-type'] || data?.type || ''; + if (data instanceof Blob && contentType.includes('json')) { + try { + error.response.data = JSON.parse(await data.text()); + } catch { + // 保留原始响应,由调用方按普通请求错误处理。 + } + } if (envConfig.debug) { console.error('请求失败:', error.response?.data || error.message); }