"""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)] 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_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'
暂无消息
'} """ @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()) rows = (await session.execute(q)).scalars().all() html = _render_chat_html(list(rows), channel, owner_account_id, peer) 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}"'}, )