feat(staff-friends): 员工客户列表独立页——对齐云客员工详情,好友数/微信群数下钻
员工列表点「好友数」原先跳全局 /customers?owner_account_id=x,布局与全局
列表无异且计数/分页绑全局声明口径(同日 dfa4fdd 修的显示 bug),与云客
「员工名下客户列表」体验割裂。新页 /staff-friends/:ownerId/:tab:
- 微信客户/微信群两 tab(对齐云客截图);标题=员工显示名(入口直带,
直链经 /api/accounts 解析),返回箭头回员工列表
- 搜索(昵称/微信号/备注名/手机号,服务端 q)+ 查询/导出 CSV
- 发送/接收条数=Customer.send_num/receive_num 累计值(CustomerOut 补
两列,同步自云客 friend 记录,与截图同源)
- 勾选批量「导出聊天记录」:新端点 GET /api/export/chat-html-zip,复用
chat-html 渲染器每会话一个 HTML 打 zip(文件名=备注>昵称>friend_id,
非法字符清洗;单会话 5000 行截断告警;一次上限 50 会话防误选打爆)
- 行内:查看聊天记录→staff-chat;导出HTML聊天记录(时间范围弹窗)
- WA 行保持原入口(该页暂仅微信)
后端 605 测试绿(zip 新 4 例);本地浏览器端到端验证:过滤 9 条/勾选
激活/群 tab/返回箭头;zip 产物 application/zip 双会话双气泡。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,25 @@
|
||||
"""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 返回下载。
|
||||
作为 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, Query, Response
|
||||
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 Message
|
||||
from app.db.models import Customer, Message
|
||||
|
||||
router = APIRouter(prefix="/api/export", tags=["export"])
|
||||
|
||||
@@ -170,3 +174,92 @@ async def export_chat_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} 个会话"
|
||||
)
|
||||
|
||||
# 文件名显示名:批量取一次 Customer(备注 > 昵称 > friend_id)
|
||||
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_(ids),
|
||||
)
|
||||
)).all()
|
||||
names = {
|
||||
r.friend_id: (r.friend_remark or r.friend_nickname or r.friend_id)
|
||||
for r in cust_rows
|
||||
}
|
||||
|
||||
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"'
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -39,6 +39,10 @@ class CustomerOut(BaseModel):
|
||||
last_chat_time: int | None = None
|
||||
add_time: int | None = None
|
||||
message_count: int | None = None
|
||||
# 累计发送/接收条数(同步自云客 friend 记录的 sendNum/receiveNum;员工
|
||||
# 客户页「发送条数/接收条数」列用,2026-09-08)
|
||||
send_num: int | None = None
|
||||
receive_num: int | None = None
|
||||
version: str | None = None
|
||||
# Verbatim upstream friend record (~60 camelCase fields). Surfaced so the
|
||||
# all-fields drawer can render every column the 云客 source page shows
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
"""Tests for GET /api/export/chat-html — 导出 1:1/群聊天为自包含 HTML。
|
||||
"""Tests for GET /api/export/chat-html(+zip) — 导出 1:1/群聊天为自包含 HTML。
|
||||
|
||||
对齐云客微信客户/群列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
|
||||
拉消息(可选 start/end 时间范围),渲染为左右气泡 HTML 作为 attachment 返回下载。
|
||||
zip 版:员工客户页勾选多个客户批量「导出聊天记录」(2026-09-08)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import io
|
||||
import zipfile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.routers.export import EXPORT_MAX_ROWS
|
||||
from app.db.models import Message
|
||||
from app.api.routers.export import EXPORT_MAX_ROWS, ZIP_MAX_CONVS
|
||||
from app.db.models import Customer, Message
|
||||
|
||||
|
||||
async def _seed(session: AsyncSession) -> None:
|
||||
@@ -160,3 +163,91 @@ async def test_export_group_ignores_owner_mismatch(make_client):
|
||||
assert r.status_code == 200
|
||||
assert "群消息1" in r.text
|
||||
assert "群消息2" in r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/export/chat-html-zip — 批量导出(2026-09-08 员工客户页)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _seed_zip(session: AsyncSession) -> None:
|
||||
"""两个客户各几条 DM + 一条同 talker 的群消息(不应混入 DM zip)。"""
|
||||
session.add_all([
|
||||
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f1",
|
||||
friend_nickname="小蜜", friend_remark="蜜姐"),
|
||||
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f2",
|
||||
friend_nickname="老王"),
|
||||
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f3",
|
||||
friend_nickname='坏/名字<>:"|'),
|
||||
Message(msg_id="z1", channel="wechat", owner_account_id="wx_owner",
|
||||
talker_id="f1", room_id=None, text="f1你好", timestamp=1000,
|
||||
is_send=False, type="text", type_raw=1),
|
||||
Message(msg_id="z2", channel="wechat", owner_account_id="wx_owner",
|
||||
talker_id="f2", room_id=None, text="f2在吗", timestamp=2000,
|
||||
is_send=True, type="text", type_raw=1),
|
||||
Message(msg_id="z3", channel="wechat", owner_account_id="wx_owner",
|
||||
talker_id="f2", room_id=None, text="f2更早", timestamp=500,
|
||||
is_send=True, type="text", type_raw=1),
|
||||
# f1 名下的群消息:room_id 有值,zip 的 DM 查询必须排除
|
||||
Message(msg_id="z4", channel="wechat", owner_account_id="wx_owner",
|
||||
talker_id="f1", room_id="g@chatroom", text="群内消息不进zip",
|
||||
timestamp=3000, is_send=True, type="text", type_raw=1),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _zip_entries(r) -> dict[str, str]:
|
||||
assert r.headers["content-type"].startswith("application/zip")
|
||||
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||
return {name: zf.read(name).decode("utf-8") for name in zf.namelist()}
|
||||
|
||||
|
||||
async def test_zip_two_conversations_with_display_names(make_client):
|
||||
"""zip 含两个会话 HTML;文件名取备注>昵称;群消息不混入。"""
|
||||
client = await make_client(_seed_zip)
|
||||
r = await client.get("/api/export/chat-html-zip", params={
|
||||
"channel": "wechat", "owner_account_id": "wx_owner",
|
||||
"talker_ids": "f1,f2"})
|
||||
assert r.status_code == 200
|
||||
assert "attachment" in r.headers["content-disposition"]
|
||||
entries = _zip_entries(r)
|
||||
assert sorted(entries) == ["01_蜜姐.html", "02_老王.html"]
|
||||
assert "f1你好" in entries["01_蜜姐.html"]
|
||||
assert "f2在吗" in entries["02_老王.html"]
|
||||
# DM 查询排除 room 消息
|
||||
assert "群内消息不进zip" not in "".join(entries.values())
|
||||
|
||||
|
||||
async def test_zip_time_range_filter(make_client):
|
||||
"""start/end 同样作用于 zip 内每个会话。"""
|
||||
client = await make_client(_seed_zip)
|
||||
r = await client.get("/api/export/chat-html-zip", params={
|
||||
"channel": "wechat", "owner_account_id": "wx_owner",
|
||||
"talker_ids": "f2", "start": 1000})
|
||||
assert r.status_code == 200
|
||||
entries = _zip_entries(r)
|
||||
assert "f2在吗" in entries["01_老王.html"]
|
||||
assert "f2更早" not in entries["01_老王.html"]
|
||||
|
||||
|
||||
async def test_zip_filename_sanitized_and_fallback(make_client):
|
||||
"""非法字符客户名清洗;无 customer 行的 talker 回退 friend_id。"""
|
||||
client = await make_client(_seed_zip)
|
||||
r = await client.get("/api/export/chat-html-zip", params={
|
||||
"channel": "wechat", "owner_account_id": "wx_owner",
|
||||
"talker_ids": "f3,f_unknown"})
|
||||
assert r.status_code == 200
|
||||
names = sorted(_zip_entries(r))
|
||||
assert names[0].startswith("01_") and "/" not in names[0] and "<" not in names[0]
|
||||
assert names[1] == "02_f_unknown.html"
|
||||
|
||||
|
||||
async def test_zip_rejects_empty_and_over_limit(make_client):
|
||||
"""空列表/超上限 422,防误传。"""
|
||||
client = await make_client(_seed_zip)
|
||||
r_empty = await client.get("/api/export/chat-html-zip", params={
|
||||
"channel": "wechat", "owner_account_id": "wx_owner", "talker_ids": " , "})
|
||||
assert r_empty.status_code == 422
|
||||
many = ",".join(f"t{i}" for i in range(ZIP_MAX_CONVS + 1))
|
||||
r_many = await client.get("/api/export/chat-html-zip", params={
|
||||
"channel": "wechat", "owner_account_id": "wx_owner", "talker_ids": many})
|
||||
assert r_many.status_code == 422
|
||||
|
||||
Reference in New Issue
Block a user