feat: 修改 400问题
This commit is contained in:
+21
-2
@@ -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))
|
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:
|
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
|
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
continue # 单张下载失败不中断整包
|
continue # 单张下载失败不中断整包
|
||||||
if not _is_image(img.url, ctype):
|
ctype = _image_content_type(data, img.url, ctype)
|
||||||
|
if ctype is None:
|
||||||
continue
|
continue
|
||||||
total_source_bytes += len(data)
|
total_source_bytes += len(data)
|
||||||
if total_source_bytes > settings.export_zip_max_bytes:
|
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
|
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise HTTPException(status_code=422, detail="图片下载失败") from exc
|
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="该地址不是图片")
|
raise HTTPException(status_code=400, detail="该地址不是图片")
|
||||||
|
|
||||||
ext = _ext(img.url, ctype)
|
ext = _ext(img.url, ctype)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import zipfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||||
if str(SERVER_DIR) not in sys.path:
|
if str(SERVER_DIR) not in sys.path:
|
||||||
sys.path.insert(0, str(SERVER_DIR))
|
sys.path.insert(0, str(SERVER_DIR))
|
||||||
@@ -16,6 +18,23 @@ from api.export import ExportImageItem, ExportImagesRequest, export_image, expor
|
|||||||
|
|
||||||
|
|
||||||
class ExportImageTests(unittest.IsolatedAsyncioTestCase):
|
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:
|
async def test_chinese_filename_uses_rfc5987_header(self) -> None:
|
||||||
image = ExportImageItem(
|
image = ExportImageItem(
|
||||||
url="https://example.com/image.png",
|
url="https://example.com/image.png",
|
||||||
|
|||||||
@@ -803,6 +803,12 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
});
|
});
|
||||||
const name = cleanFilename(img.name || img.key) || '生图结果';
|
const name = cleanFilename(img.name || img.key) || '生图结果';
|
||||||
const suffix = (() => {
|
const suffix = (() => {
|
||||||
|
const mimeExt: Record<string, string> = {
|
||||||
|
'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 {
|
try {
|
||||||
return new URL(img.url, window.location.origin).pathname.match(/\.(jpe?g|png|webp|gif|bmp)$/i)?.[0] ?? '.jpg';
|
return new URL(img.url, window.location.origin).pathname.match(/\.(jpe?g|png|webp|gif|bmp)$/i)?.[0] ?? '.jpg';
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -11,7 +11,17 @@ const apiClient: AxiosInstance = axios.create({
|
|||||||
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(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) {
|
if (envConfig.debug) {
|
||||||
console.error('请求失败:', error.response?.data || error.message);
|
console.error('请求失败:', error.response?.data || error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user