feat(api): GET /api/export/chat-html — 导出 1:1/群聊天为 HTML
支持 DM(talker_id) 与 群(room_id),可选 start/end 时间范围; is_send 决定气泡左右。对齐云客微信客户/群列表「导出HTML聊天记录」。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ Includes:
|
|||||||
* ``GET /api/sms`` (see :mod:`app.api.routers.sms`).
|
* ``GET /api/sms`` (see :mod:`app.api.routers.sms`).
|
||||||
* ``GET /api/sync/status`` (see :mod:`app.api.routers.sync`).
|
* ``GET /api/sync/status`` (see :mod:`app.api.routers.sync`).
|
||||||
* ``GET /api/search`` (see :mod:`app.api.routers.search`).
|
* ``GET /api/search`` (see :mod:`app.api.routers.search`).
|
||||||
|
* ``GET /api/export/chat-html`` (see :mod:`app.api.routers.export`).
|
||||||
|
|
||||||
CORS is configured permissively (wildcard origin, no credentials) so the local
|
CORS is configured permissively (wildcard origin, no credentials) so the local
|
||||||
frontend can call the API during development.
|
frontend can call the API during development.
|
||||||
@@ -38,6 +39,7 @@ from app.api.auth import require_auth
|
|||||||
from app.api.routers import (
|
from app.api.routers import (
|
||||||
calls,
|
calls,
|
||||||
customer,
|
customer,
|
||||||
|
export,
|
||||||
groups,
|
groups,
|
||||||
media,
|
media,
|
||||||
messages,
|
messages,
|
||||||
@@ -65,6 +67,7 @@ app.include_router(calls.router)
|
|||||||
app.include_router(sms.router)
|
app.include_router(sms.router)
|
||||||
app.include_router(sync.router)
|
app.include_router(sync.router)
|
||||||
app.include_router(search.router)
|
app.include_router(search.router)
|
||||||
|
app.include_router(export.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""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.replace("@", "_").replace("/", "_") 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}"'},
|
||||||
|
)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Tests for GET /api/export/chat-html — 导出 1:1/群聊天为自包含 HTML。
|
||||||
|
|
||||||
|
对齐云客微信客户/群列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
|
||||||
|
拉消息(可选 start/end 时间范围),渲染为左右气泡 HTML 作为 attachment 返回下载。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.models import Message
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed(session: AsyncSession) -> None:
|
||||||
|
session.add_all([
|
||||||
|
Message(msg_id="dm1", channel="wechat", owner_account_id="wx_owner",
|
||||||
|
talker_id="wx_friend", room_id=None, text="你好",
|
||||||
|
timestamp=1000, is_send=False, type="text", type_raw=1),
|
||||||
|
Message(msg_id="dm2", channel="wechat", owner_account_id="wx_owner",
|
||||||
|
talker_id="wx_friend", room_id=None, text="在的",
|
||||||
|
timestamp=2000, is_send=True, type="text", type_raw=1),
|
||||||
|
Message(msg_id="g1", channel="wechat", owner_account_id="wx_owner",
|
||||||
|
talker_id="room_topic", room_id="g1@chatroom", text="群消息",
|
||||||
|
timestamp=3000, is_send=True, type="text", type_raw=1),
|
||||||
|
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),
|
||||||
|
])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_export_dm_returns_html(make_client):
|
||||||
|
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
|
||||||
|
assert r.headers["content-type"].startswith("text/html")
|
||||||
|
assert "attachment" in r.headers["content-disposition"]
|
||||||
|
body = r.text
|
||||||
|
assert "你好" in body and "在的" in body
|
||||||
|
assert "wx_owner" in body
|
||||||
|
|
||||||
|
|
||||||
|
async def test_export_group_with_time_range(make_client):
|
||||||
|
client = await make_client(_seed)
|
||||||
|
r = await client.get("/api/export/chat-html", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_owner", "room_id": "g1@chatroom",
|
||||||
|
"start": 0, "end": 5000})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "群消息" in r.text
|
||||||
|
assert "范围外" not in r.text # end=5000 过滤掉 timestamp=9999999
|
||||||
|
|
||||||
|
|
||||||
|
async def test_export_empty(make_client):
|
||||||
|
client = await make_client(_seed)
|
||||||
|
r = await client.get("/api/export/chat-html", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "none"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"].startswith("text/html")
|
||||||
Reference in New Issue
Block a user