"""GET /api/export/chat-html — 导出一段 1:1 / 群聊天为独立 HTML 文件。 对齐云客微信客户列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id) 拉消息(可选 start/end 时间范围),渲染自包含 HTML 聊天页(is_send 决定左右气泡), 作为 attachment 返回下载。 """ from __future__ import annotations from datetime import datetime, timezone from html import escape from typing import Annotated import sqlalchemy as sa from fastapi import APIRouter, Depends, Query, Response from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_session from app.db.models import Message router = APIRouter(prefix="/api/export", tags=["export"]) SessionDep = Annotated[AsyncSession, Depends(get_session)] # Hard cap on rows an HTML export will load + render. An account-wide export # with no date range can match millions of messages; rendering all of them in # memory risks OOM. The cap is a safety valve, not a tight budget — at 5k rows # the HTML is already unwieldy in any browser. When hit, the response carries # a visible warning banner so the operator knows to narrow the date range. EXPORT_MAX_ROWS = 5000 def _ts_text(ts: int | None) -> str: if not ts: return "" try: return datetime.fromtimestamp(ts / 1000, tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") except (ValueError, OSError, OverflowError): 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, *, warning: str = "", ) -> str: bubbles = [] for m in rows: side = "right" if m.is_send else "left" ts = escape(_ts_text(getattr(m, "timestamp", None))) 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}{warning}

{title}

{''.join(bubbles) or '

暂无消息

'} """ @router.get("/chat-html") async def export_chat_html( session: SessionDep, channel: Annotated[str, Query()], owner_account_id: Annotated[str, Query()], talker_id: Annotated[str | None, Query()] = None, room_id: Annotated[str | None, Query()] = None, start: Annotated[int | None, Query(ge=0)] = None, end: Annotated[int | None, Query(ge=0)] = None, ) -> Response: # 群聊(room_id)不按 owner 过滤:chat_group 表按 room 去重、owner 被末位 # 员工覆盖,与消息实际归属 owner 错位会导出空文件(同 ChatTimeline 修复, # 2026-08-22);room 全局唯一且消息只在单一 owner 名下。 q = sa.select(Message).where(Message.channel == channel) peer = "" if room_id: q = q.where(Message.room_id == room_id) peer = room_id else: q = q.where(Message.owner_account_id == owner_account_id) if talker_id: # DM:限定 room_id 为空,避免误捞群消息 q = q.where(Message.talker_id == talker_id).where( (Message.room_id == None) | (Message.room_id == "") # noqa: E711 ) peer = talker_id if start is not None: q = q.where(Message.timestamp >= start) if end is not None: q = q.where(Message.timestamp <= end) q = q.order_by(sa.nullslast(Message.timestamp.asc()), Message.msg_id.asc()).limit( EXPORT_MAX_ROWS ) rows = (await session.execute(q)).scalars().all() # If we filled the cap there may be more rows beyond it — surface a visible # warning so the operator knows the export was truncated and can re-run # with a tighter date range. capped = len(rows) >= EXPORT_MAX_ROWS warning = ( f'
⚠️ 结果超过 {EXPORT_MAX_ROWS} 条,' f'仅导出前 {EXPORT_MAX_ROWS} 条。请缩小日期范围。
' if capped else "" ) html = _render_chat_html(list(rows), channel, owner_account_id, peer, warning=warning) safe = peer or "all" filename = f"chat-{channel}-{owner_account_id}-{safe}.html".replace("@", "_").replace("/", "_") return Response( content=html, media_type="text/html; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, )