From a5aa800fb122f0613970521f5a4128c19340b59a Mon Sep 17 00:00:00 2001 From: AeBoPi <109503402+AeBoPi@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:19:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=B7=B2=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E7=9A=84=E4=B8=8A=E4=BC=A0=E5=9B=BE=E7=89=87=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/core/json_content.py | 12 ++++++++++ backend/core/json_renderer.py | 37 +++++++++++++++++++++-------- backend/tests/test_json_content.py | 24 ++++++++++++++++++- backend/tests/test_json_renderer.py | 20 +++++++++++++++- 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/backend/core/json_content.py b/backend/core/json_content.py index 3bf379cd..b0b3ed84 100644 --- a/backend/core/json_content.py +++ b/backend/core/json_content.py @@ -83,6 +83,14 @@ def _resolve_uploaded_image_bytes(url: str) -> bytes | None: return None +def _is_uploaded_image_url(url: str) -> bool: + try: + parsed = urlparse(url) + except ValueError: + return False + return (parsed.path or "").startswith("/api/uploads/") + + def _has_cjk_text(text: str) -> bool: return any("\u4e00" <= ch <= "\u9fff" for ch in str(text or "")) @@ -521,6 +529,10 @@ async def _prefetch_images(content: dict, mode_def: dict) -> dict: if local_bytes: content[f"_prefetched_{field_name}"] = local_bytes continue + if _is_uploaded_image_url(url): + content[f"_invalid_{field_name}"] = "Image link expired" + logger.warning("[JSONContent] Uploaded image link expired for field %s: %s", field_name, url) + continue try: resp = await client.get(url) if resp.status_code < 400: diff --git a/backend/core/json_renderer.py b/backend/core/json_renderer.py index 4011471d..43fe841c 100644 --- a/backend/core/json_renderer.py +++ b/backend/core/json_renderer.py @@ -2517,6 +2517,26 @@ def _resolve_under(root: Path, rel_path: str) -> str | None: return None +def _is_upload_url(url: str) -> bool: + try: + parsed = urlparse(url) + except ValueError: + return False + path = parsed.path or url + return path.startswith("/api/uploads/") + + +def _draw_image_placeholder(ctx: RenderContext, x: int, y: int, width: int, height: int, text: str) -> None: + ctx.draw.rectangle([x, y, x + width, y + height], outline=EINK_FG, width=1) + placeholder_font = load_font("noto_serif_light", int(12 * ctx.scale)) + bbox = placeholder_font.getbbox(text) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + tx = x + (width - tw) // 2 + ty = y + (height - th) // 2 + ctx.draw.text((tx, ty), text, fill=EINK_FG, font=placeholder_font) + + def _render_image(ctx: RenderContext, block: dict) -> None: field_name = block.get("field", "image_url") image_url = str(ctx.get_field(field_name) or "") @@ -2572,6 +2592,11 @@ def _render_image(ctx: RenderContext, block: dict) -> None: return except (OSError, UnidentifiedImageError): logger.warning("[JSONRenderer] Failed to load local asset %s", local_path, exc_info=True) + elif _is_upload_url(image_url): + logger.warning("[JSONRenderer] Uploaded image link expired: %s", image_url) + _draw_image_placeholder(ctx, x, y, width, height, "Image link expired") + ctx.y = y + height + margin_bottom + return try: resp = None last_error = None @@ -2612,16 +2637,8 @@ def _render_image(ctx: RenderContext, block: dict) -> None: ctx.paste_icon(img, (x, y)) ctx.y = y + height + margin_bottom except (httpx.HTTPError, ValueError, OSError, UnidentifiedImageError): - logger.warning("[JSONRenderer] Failed to render image block", exc_info=True) - ctx.draw.rectangle([x, y, x + width, y + height], outline=EINK_FG, width=1) - placeholder_font = load_font("noto_serif_light", int(12 * ctx.scale)) - placeholder_text = "Image unavailable" - bbox = placeholder_font.getbbox(placeholder_text) - tw = bbox[2] - bbox[0] - th = bbox[3] - bbox[1] - tx = x + (width - tw) // 2 - ty = y + (height - th) // 2 - ctx.draw.text((tx, ty), placeholder_text, fill=EINK_FG, font=placeholder_font) + logger.warning("[JSONRenderer] Failed to render image block: %s", image_url, exc_info=True) + _draw_image_placeholder(ctx, x, y, width, height, "Image link expired") ctx.y = y + height + int(block.get("margin_bottom", 6) * ctx.scale) diff --git a/backend/tests/test_json_content.py b/backend/tests/test_json_content.py index fae6fc23..c2d2e76f 100644 --- a/backend/tests/test_json_content.py +++ b/backend/tests/test_json_content.py @@ -18,6 +18,7 @@ _parse_json_output, _parse_llm_json_output, _apply_post_process, + _prefetch_images, generate_json_mode_content, ) @@ -223,6 +224,28 @@ def test_apply_post_process_skips_non_string(): assert result["items"] == [1, 2, 3] +@pytest.mark.asyncio +async def test_prefetch_missing_uploaded_image_does_not_fetch_remote(): + mode_def = { + "layout": { + "body": [ + {"type": "image", "field": "image_url"}, + ], + }, + } + content = { + "image_url": "https://www.inksight.site/api/uploads/00000000-0000-4000-8000-000000000000", + } + + with patch("core.json_content.httpx.AsyncClient") as mock_client: + client = mock_client.return_value.__aenter__.return_value + client.get = AsyncMock() + result = await _prefetch_images(dict(content), mode_def) + + client.get.assert_not_awaited() + assert result["_invalid_image_url"] == "Image link expired" + + @pytest.mark.asyncio async def test_llm_key_missing_returns_fallback(): """当 LLM API key 缺失时,应返回 fallback 内容而非抛出异常""" @@ -603,4 +626,3 @@ async def test_almanac_api_uses_cache_db_across_calls(tmp_path): assert mock_tip.await_count == 1 finally: await db_mod.close_all() - diff --git a/backend/tests/test_json_renderer.py b/backend/tests/test_json_renderer.py index 619cb4d8..68e013a8 100644 --- a/backend/tests/test_json_renderer.py +++ b/backend/tests/test_json_renderer.py @@ -5,6 +5,7 @@ import os import sys from io import BytesIO +from unittest.mock import patch sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -675,6 +676,24 @@ def test_render_image_block_cover_fit_fills_frame(): assert img.getpixel((119, 99)) == 255 +def test_render_missing_uploaded_image_does_not_fetch_remote(): + mode_def = _make_mode_def([ + {"type": "image", "field": "image_url", "width": 120, "height": 36, "x": 100, "y": 80} + ]) + content = { + "image_url": "https://www.inksight.site/api/uploads/00000000-0000-4000-8000-000000000000", + } + + with patch("core.json_renderer.httpx.Client") as mock_client: + img = render_json_mode( + mode_def, content, + date_str="2月18日", weather_str="晴", battery_pct=80, + ).convert("L") + + mock_client.assert_not_called() + assert img.getpixel((100, 80)) == 0 + + def test_render_forecast_cards_supports_custom_fields(): mode_def = _make_mode_def([ { @@ -794,4 +813,3 @@ def test_render_context_resolve(): assert ctx.resolve("{count} items") == "42 items" assert ctx.resolve("no placeholders") == "no placeholders" assert ctx.resolve("{missing}") == "" -