2b516362cd
用户报 Lily 聊天窗口「聊天好友」列表里 A**郑 显示成 wxid_ir6uxy2li8no22 ——A**郑是 14 个员工共享的客户,档案行(customer 全局单行 last-wins)挂在 别的员工名下,按 owner 查档案查空,三个显示位回退 wxid: - sessions._meta(会话列表名字/头像/delete_flag) - staff_chat_stats._customer_names(五数字弹窗明细) - chat-html-zip 文件名 统一改为优先 customer_account_link per-owner 快照(link 未覆盖回退档案, 空表过渡安全)。staff_stats(统计分析新客户等)仍是档案口径,属分析语义 另行评估。613 测试绿(新增会话共享好友昵称 2 例)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
284 lines
12 KiB
Python
284 lines
12 KiB
Python
"""GET /api/export/chat-html — 导出一段 1:1 / 群聊天为独立 HTML 文件。
|
||
GET /api/export/chat-html-zip — 勾选的多个 1:1 会话打成一个 zip(每会话一个 HTML)。
|
||
|
||
对齐云客微信客户列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
|
||
拉消息(可选 start/end 时间范围),渲染自包含 HTML 聊天页(is_send 决定左右气泡),
|
||
作为 attachment 返回下载。zip 版对齐员工客户页的批量「导出聊天记录」(2026-09-08)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import re
|
||
import zipfile
|
||
from datetime import datetime, timezone
|
||
from html import escape
|
||
from typing import Annotated
|
||
|
||
import sqlalchemy as sa
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.api.deps import get_session
|
||
from app.db.models import Customer, CustomerAccountLink, 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 '<div class="media">📎 [本地媒体]</div>'
|
||
|
||
# Only embed full URLs; anything else (bare path, data URI, etc.) → note
|
||
if not (file_url.startswith("http://") or file_url.startswith("https://")):
|
||
return '<div class="media">📎 [本地媒体]</div>'
|
||
|
||
url = escape(file_url, quote=True)
|
||
if msg_type == "image":
|
||
return (
|
||
f'<div class="media"><img src="{url}" alt="图片" loading="lazy"'
|
||
f' style="max-width:100%;border-radius:4px;margin:4px 0"/></div>'
|
||
)
|
||
if msg_type in ("voice", "audio"):
|
||
duration = getattr(m, "duration", None)
|
||
dur_label = f" ({duration}秒)" if duration else ""
|
||
return (
|
||
f'<div class="media"><audio controls preload="none" src="{url}"'
|
||
f' style="max-width:100%"></audio><span class="dur">{dur_label}</span></div>'
|
||
)
|
||
if msg_type == "video":
|
||
return (
|
||
f'<div class="media"><video controls preload="none" src="{url}"'
|
||
f' style="max-width:100%;border-radius:4px"></video></div>'
|
||
)
|
||
if msg_type == "file":
|
||
return f'<div class="media"><a href="{url}" target="_blank">📎 附件</a></div>'
|
||
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'<div class="b">{text_html}</div>{media_html}'
|
||
else:
|
||
body = '<div class="b">(空消息)</div>'
|
||
bubbles.append(f'<div class="bubble {side}"><div class="t">{ts}</div>{body}</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}}
|
||
.export-warning{{background:#fff3cd;border:1px solid #ffeeba;color:#856404;padding:8px 12px;border-radius:4px;margin:0 0 12px;font-size:13px}}
|
||
.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}}
|
||
.bubble .media{{margin-top:4px;font-size:13px}}
|
||
.bubble .media audio,.bubble .media video,.bubble .media img{{display:block;max-width:100%}}
|
||
.bubble .media .dur{{font-size:12px;color:#666;margin-left:4px}}
|
||
</style></head><body>{warning}<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:
|
||
# 群聊(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'<div class="export-warning">⚠️ 结果超过 {EXPORT_MAX_ROWS} 条,'
|
||
f'仅导出前 {EXPORT_MAX_ROWS} 条。请缩小日期范围。</div>'
|
||
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}"'},
|
||
)
|
||
|
||
|
||
# 员工客户页批量「导出聊天记录」:一次勾选的会话数上限(防误点全选把服务端打爆;
|
||
# 50 个会话 × 每会话 EXPORT_MAX_ROWS 已是内存上界内的最坏情况)。
|
||
ZIP_MAX_CONVS = 50
|
||
|
||
|
||
def _safe_zip_name(name: str, fallback: str) -> str:
|
||
"""会话显示名 → zip 内文件名:去路径/Windows 非法字符,限长,空回退。"""
|
||
cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip("._ ")
|
||
return cleaned[:40] or fallback
|
||
|
||
|
||
@router.get("/chat-html-zip")
|
||
async def export_chat_html_zip(
|
||
session: SessionDep,
|
||
channel: Annotated[str, Query()],
|
||
owner_account_id: Annotated[str, Query()],
|
||
talker_ids: Annotated[str, Query()] = "",
|
||
start: Annotated[int | None, Query(ge=0)] = None,
|
||
end: Annotated[int | None, Query(ge=0)] = None,
|
||
) -> Response:
|
||
"""勾选客户的 1:1 聊天批量导出为一个 zip(每会话一个 HTML,复用单导出渲染器)。
|
||
|
||
``talker_ids`` 逗号分隔的 friend_id 列表;文件名取客户备注/昵称(缺省回退
|
||
friend_id),加序号前缀保唯一;单会话行数沿用 EXPORT_MAX_ROWS 截断告警。
|
||
"""
|
||
ids = [t.strip() for t in talker_ids.split(",") if t.strip()]
|
||
if not ids:
|
||
raise HTTPException(status_code=422, detail="talker_ids 不能为空")
|
||
if len(ids) > ZIP_MAX_CONVS:
|
||
raise HTTPException(
|
||
status_code=422, detail=f"一次最多导出 {ZIP_MAX_CONVS} 个会话"
|
||
)
|
||
|
||
# 文件名显示名:批量取一次(备注 > 昵称 > friend_id)。优先
|
||
# customer_account_link per-owner 快照——共享客户的档案行可能挂在别的
|
||
# 员工名下,按 owner 查档案会查空、zip 文件名回退成 wxid(2026-09-09,
|
||
# 同 sessions._meta 修复);link 没覆盖的回退档案。
|
||
link_rows = (await session.execute(
|
||
sa.select(
|
||
CustomerAccountLink.friend_id,
|
||
CustomerAccountLink.friend_nickname,
|
||
CustomerAccountLink.friend_remark,
|
||
).where(
|
||
CustomerAccountLink.channel == channel,
|
||
CustomerAccountLink.owner_account_id == owner_account_id,
|
||
CustomerAccountLink.friend_id.in_(ids),
|
||
)
|
||
)).all()
|
||
names = {
|
||
r.friend_id: (r.friend_remark or r.friend_nickname or r.friend_id)
|
||
for r in link_rows
|
||
}
|
||
missing = [t for t in ids if t not in names]
|
||
if missing:
|
||
cust_rows = (await session.execute(
|
||
sa.select(
|
||
Customer.friend_id,
|
||
Customer.friend_nickname,
|
||
Customer.friend_remark,
|
||
).where(
|
||
Customer.channel == channel,
|
||
Customer.owner_account_id == owner_account_id,
|
||
Customer.friend_id.in_(missing),
|
||
)
|
||
)).all()
|
||
for r in cust_rows:
|
||
names[r.friend_id] = (r.friend_remark or r.friend_nickname or r.friend_id)
|
||
|
||
buf = io.BytesIO()
|
||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||
for i, tid in enumerate(ids, 1):
|
||
q = sa.select(Message).where(
|
||
Message.channel == channel,
|
||
Message.owner_account_id == owner_account_id,
|
||
Message.talker_id == tid,
|
||
# DM:限定 room_id 为空,避免误捞群消息(同单导出)
|
||
(Message.room_id == None) | (Message.room_id == ""), # noqa: E711
|
||
)
|
||
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 = list((await session.execute(q)).scalars().all())
|
||
capped = len(rows) >= EXPORT_MAX_ROWS
|
||
warning = (
|
||
f'<div class="export-warning">⚠️ 结果超过 {EXPORT_MAX_ROWS} 条,'
|
||
f"仅导出前 {EXPORT_MAX_ROWS} 条。请缩小日期范围。</div>"
|
||
if capped
|
||
else ""
|
||
)
|
||
html = _render_chat_html(rows, channel, owner_account_id, tid, warning=warning)
|
||
safe = _safe_zip_name(names.get(tid) or tid, f"conv{i}")
|
||
zf.writestr(f"{i:02d}_{safe}.html", html)
|
||
return Response(
|
||
content=buf.getvalue(),
|
||
media_type="application/zip",
|
||
headers={
|
||
"Content-Disposition": (
|
||
f'attachment; filename="chat-records-{channel}-{owner_account_id}.zip"'
|
||
)
|
||
},
|
||
)
|