a66c3be00a
address review Minors: drop redundant filename replace, assert 暂无消息, tighten downloadBlob return type + try/finally, fix plan group time-picker wording. Co-Authored-By: Claude <noreply@anthropic.com>
97 lines
3.7 KiB
Python
97 lines
3.7 KiB
Python
"""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'<div class="bubble {side}"><div class="t">{ts}</div><div class="b">{text}</div></div>'
|
|
)
|
|
title = f"聊天记录 · {escape(channel)} · {escape(owner)} / {escape(peer)}"
|
|
return f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
|
<title>{title}</title><style>
|
|
body{{font-family:-apple-system,Segoe UI,sans-serif;background:#f5f5f5;margin:0;padding:16px}}
|
|
h1{{font-size:16px;margin:0 0 12px}}
|
|
.bubble{{max-width:70%;margin:6px 0;padding:8px 12px;border-radius:8px;clear:both}}
|
|
.bubble.right{{float:right;background:#95ec69;text-align:right}}
|
|
.bubble.left{{float:left;background:#fff}}
|
|
.bubble .t{{font-size:11px;color:#999;margin-bottom:2px}}
|
|
.bubble .b{{font-size:14px;white-space:pre-wrap;word-break:break-word}}
|
|
</style></head><body><h1>{title}</h1>
|
|
{''.join(bubbles) or '<p>暂无消息</p>'}
|
|
</body></html>"""
|
|
|
|
|
|
@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}"'},
|
|
)
|