Files
yunke/yunke-server/app/api/routers/export.py
T
浅念 2b516362cd fix(names): 会话/统计弹窗/zip 文件名的共享好友昵称读 link 快照
用户报 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>
2026-09-09 11:44:58 +08:00

284 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"'
)
},
)