"""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 '
暂无消息
'} """ @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: q = ( sa.select(Message) .where(Message.channel == channel) .where(Message.owner_account_id == owner_account_id) ) peer = "" if room_id: q = q.where(Message.room_id == room_id) peer = room_id elif 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'