From af7f8d71c88b32ad7800ced8cb62a1be706bafe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 11 Aug 2026 17:22:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(export):=20=E5=AF=BC=E5=87=BA=20HTML=20?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E5=86=85=E5=B5=8C(=E5=9B=BE=E7=89=87/?= =?UTF-8?q?=E8=AF=AD=E9=9F=B3/=E8=A7=86=E9=A2=91/=E9=99=84=E4=BB=B6,=20OSS?= =?UTF-8?q?=20URL=20=E7=9B=B4=E6=8E=A5=E5=B5=8C=E5=85=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- yunke-server/app/api/routers/export.py | 56 ++++++++++++++++++++++++-- yunke-server/tests/test_api_export.py | 25 ++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/yunke-server/app/api/routers/export.py b/yunke-server/app/api/routers/export.py index d34a9f6..33c9a97 100644 --- a/yunke-server/app/api/routers/export.py +++ b/yunke-server/app/api/routers/export.py @@ -31,15 +31,60 @@ def _ts_text(ts: int | None) -> str: return str(ts) +def _render_media(m) -> str: + """Render media (image/voice/video/file) HTML for a message, or empty string. + + OSS URLs (http/https) embed directly. Local /api/ paths can't resolve in a + standalone HTML file (opened from disk, not served by backend) → show a note. + """ + msg_type = getattr(m, "type", None) or "" + file_url = getattr(m, "file_url", None) or "" + if not file_url: + return "" + + # Local backend path → not accessible from a downloaded HTML file + if file_url.startswith("/api/"): + return '
📎 [本地媒体]
' + + # Only embed full URLs; anything else (bare path, data URI, etc.) → note + if not (file_url.startswith("http://") or file_url.startswith("https://")): + return '
📎 [本地媒体]
' + + url = escape(file_url, quote=True) + if msg_type == "image": + return ( + f'
图片
' + ) + if msg_type in ("voice", "audio"): + duration = getattr(m, "duration", None) + dur_label = f" ({duration}秒)" if duration else "" + return ( + f'
{dur_label}
' + ) + if msg_type == "video": + return ( + f'
' + ) + if msg_type == "file": + return f'' + return "" + + def _render_chat_html(rows: list[Message], channel: str, owner: str, peer: str) -> str: bubbles = [] for m in rows: side = "right" if m.is_send else "left" ts = escape(_ts_text(getattr(m, "timestamp", None))) - text = escape(getattr(m, "text", "") or "") - bubbles.append( - f'
{ts}
{text}
' - ) + text_html = escape(getattr(m, "text", "") or "") + media_html = _render_media(m) + if text_html or media_html: + body = f'
{text_html}
{media_html}' + else: + body = '
(空消息)
' + bubbles.append(f'
{ts}
{body}
') title = f"聊天记录 · {escape(channel)} · {escape(owner)} / {escape(peer)}" return f""" {title}

{title}

{''.join(bubbles) or '

暂无消息

'} """ diff --git a/yunke-server/tests/test_api_export.py b/yunke-server/tests/test_api_export.py index 12291d3..f280b12 100644 --- a/yunke-server/tests/test_api_export.py +++ b/yunke-server/tests/test_api_export.py @@ -25,6 +25,16 @@ async def _seed(session: AsyncSession) -> None: Message(msg_id="g2", channel="wechat", owner_account_id="wx_owner", talker_id="room_topic", room_id="g1@chatroom", text="范围外", timestamp=9999999, is_send=True, type="text", type_raw=1), + # 图文消息 — OSS URL 应直接嵌入 + Message(msg_id="dm3", channel="wechat", owner_account_id="wx_owner", + talker_id="wx_friend", room_id=None, text=None, + timestamp=2500, is_send=False, type="image", type_raw=3, + file_url="https://oss.example.com/x/p1.jpg"), + # 本地 /api/media 路径 — 应显示 "[本地媒体]" 占位 + Message(msg_id="dm4", channel="wechat", owner_account_id="wx_owner", + talker_id="wx_friend", room_id=None, text=None, + timestamp=2600, is_send=True, type="voice", type_raw=34, + file_url="/api/media/abc123", duration=8), ]) await session.commit() @@ -58,3 +68,18 @@ async def test_export_empty(make_client): assert r.status_code == 200 assert r.headers["content-type"].startswith("text/html") assert "暂无消息" in r.text + + +async def test_export_embeds_media(make_client): + """图片(OSS URL)直接 嵌入;本地 /api/ 路径显示 [本地媒体] 占位。""" + client = await make_client(_seed) + r = await client.get("/api/export/chat-html", params={ + "channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "wx_friend"}) + assert r.status_code == 200 + body = r.text + # OSS URL → with the URL escaped into src + assert " + assert "[本地媒体]" in body + assert "/api/media/abc123" not in body