Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/core/json_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""))

Expand Down Expand Up @@ -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:
Expand Down
37 changes: 27 additions & 10 deletions backend/core/json_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down
24 changes: 23 additions & 1 deletion backend/tests/test_json_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
_parse_json_output,
_parse_llm_json_output,
_apply_post_process,
_prefetch_images,
generate_json_mode_content,
)

Expand Down Expand Up @@ -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 内容而非抛出异常"""
Expand Down Expand Up @@ -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()

20 changes: 19 additions & 1 deletion backend/tests/test_json_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__), ".."))

Expand Down Expand Up @@ -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([
{
Expand Down Expand Up @@ -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}") == ""

Loading