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>
This commit is contained in:
@@ -19,7 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
from app.db.models import Customer, Message
|
from app.db.models import Customer, CustomerAccountLink, Message
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/export", tags=["export"])
|
router = APIRouter(prefix="/api/export", tags=["export"])
|
||||||
|
|
||||||
@@ -209,7 +209,27 @@ async def export_chat_html_zip(
|
|||||||
status_code=422, detail=f"一次最多导出 {ZIP_MAX_CONVS} 个会话"
|
status_code=422, detail=f"一次最多导出 {ZIP_MAX_CONVS} 个会话"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 文件名显示名:批量取一次 Customer(备注 > 昵称 > friend_id)
|
# 文件名显示名:批量取一次(备注 > 昵称 > 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(
|
cust_rows = (await session.execute(
|
||||||
sa.select(
|
sa.select(
|
||||||
Customer.friend_id,
|
Customer.friend_id,
|
||||||
@@ -218,13 +238,11 @@ async def export_chat_html_zip(
|
|||||||
).where(
|
).where(
|
||||||
Customer.channel == channel,
|
Customer.channel == channel,
|
||||||
Customer.owner_account_id == owner_account_id,
|
Customer.owner_account_id == owner_account_id,
|
||||||
Customer.friend_id.in_(ids),
|
Customer.friend_id.in_(missing),
|
||||||
)
|
)
|
||||||
)).all()
|
)).all()
|
||||||
names = {
|
for r in cust_rows:
|
||||||
r.friend_id: (r.friend_remark or r.friend_nickname or r.friend_id)
|
names[r.friend_id] = (r.friend_remark or r.friend_nickname or r.friend_id)
|
||||||
for r in cust_rows
|
|
||||||
}
|
|
||||||
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
from app.api.schemas import SessionListOut, SessionOut
|
from app.api.schemas import SessionListOut, SessionOut
|
||||||
from app.db.models import ChatGroup, Customer, Message, MsgDailyStats
|
from app.db.models import (
|
||||||
|
ChatGroup,
|
||||||
|
Customer,
|
||||||
|
CustomerAccountLink,
|
||||||
|
Message,
|
||||||
|
MsgDailyStats,
|
||||||
|
)
|
||||||
from app.storage.summary import day_key, has_rows as _summary_has_rows
|
from app.storage.summary import day_key, has_rows as _summary_has_rows
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["sessions"])
|
router = APIRouter(prefix="/api", tags=["sessions"])
|
||||||
@@ -117,19 +123,42 @@ async def _summary_rows(
|
|||||||
|
|
||||||
|
|
||||||
async def _meta(session: AsyncSession, owner: str, channel: str, friend_ids: list[str], room_ids: list[str]):
|
async def _meta(session: AsyncSession, owner: str, channel: str, friend_ids: list[str], room_ids: list[str]):
|
||||||
"""查 customer/chat_group 表合并昵称/头像;好友行额外带 delete_flag(is_del 过滤用)。
|
"""查 customer_account_link(主)/customer(兜底)/chat_group 合并昵称/头像。
|
||||||
|
|
||||||
|
好友名优先读 ``customer_account_link`` 的 per-owner 快照——共享客户的
|
||||||
|
档案行(customer 全局单行,last-wins)可能挂在别的员工名下,按 owner 查
|
||||||
|
档案会查空,会话列表就回退显示 wxid(2026-09-09 用户报「有昵称却显示
|
||||||
|
微信号」);link 表每账号一行,回填后都有昵称。link 没覆盖的(空表过渡/
|
||||||
|
老数据)回退档案路径。
|
||||||
|
|
||||||
friends 值为 ``(name, avatar, delete_flag)``;群无删除标记,groups 值仍为
|
friends 值为 ``(name, avatar, delete_flag)``;群无删除标记,groups 值仍为
|
||||||
``(name, avatar)``。
|
``(name, avatar)``。
|
||||||
"""
|
"""
|
||||||
friends: dict[str, tuple[str, str | None, int | None]] = {}
|
friends: dict[str, tuple[str, str | None, int | None]] = {}
|
||||||
if friend_ids:
|
if friend_ids:
|
||||||
|
for fid, remark, nick, real, avatar, delete_flag in (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
CustomerAccountLink.friend_id, CustomerAccountLink.friend_remark,
|
||||||
|
CustomerAccountLink.friend_nickname, CustomerAccountLink.real_name,
|
||||||
|
CustomerAccountLink.head_image_url, CustomerAccountLink.delete_flag,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
CustomerAccountLink.channel == channel,
|
||||||
|
CustomerAccountLink.owner_account_id == owner,
|
||||||
|
CustomerAccountLink.friend_id.in_(friend_ids),
|
||||||
|
)
|
||||||
|
)).all():
|
||||||
|
friends[fid] = (remark or nick or real or fid, avatar, delete_flag)
|
||||||
|
# link 未覆盖的好友回退档案(按 owner 过滤;共享好友在此仍可能查空,
|
||||||
|
# 上层以 wxid 兜底显示)
|
||||||
|
missing = [fid for fid in friend_ids if fid not in friends]
|
||||||
|
if missing:
|
||||||
for fid, remark, nick, real, avatar, delete_flag in (await session.execute(
|
for fid, remark, nick, real, avatar, delete_flag in (await session.execute(
|
||||||
sa.select(
|
sa.select(
|
||||||
Customer.friend_id, Customer.friend_remark, Customer.friend_nickname,
|
Customer.friend_id, Customer.friend_remark, Customer.friend_nickname,
|
||||||
Customer.real_name, Customer.head_image_url, Customer.delete_flag,
|
Customer.real_name, Customer.head_image_url, Customer.delete_flag,
|
||||||
)
|
)
|
||||||
.where(Customer.channel == channel, Customer.owner_account_id == owner, Customer.friend_id.in_(friend_ids))
|
.where(Customer.channel == channel, Customer.owner_account_id == owner, Customer.friend_id.in_(missing))
|
||||||
)).all():
|
)).all():
|
||||||
friends[fid] = (remark or nick or real or fid, avatar, delete_flag)
|
friends[fid] = (remark or nick or real or fid, avatar, delete_flag)
|
||||||
groups: dict[str, tuple[str, str | None]] = {}
|
groups: dict[str, tuple[str, str | None]] = {}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
from app.api.schemas import StaffChatCustomerOut, StaffChatStatsOut, StaffChatStatsSummary
|
from app.api.schemas import StaffChatCustomerOut, StaffChatStatsOut, StaffChatStatsSummary
|
||||||
from app.db.models import Customer, Message, MsgDailyStats
|
from app.db.models import Customer, CustomerAccountLink, Message, MsgDailyStats
|
||||||
from app.storage.summary import day_key, has_rows as _summary_has_rows
|
from app.storage.summary import day_key, has_rows as _summary_has_rows
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["staff-chat-stats"])
|
router = APIRouter(prefix="/api", tags=["staff-chat-stats"])
|
||||||
@@ -144,8 +144,30 @@ async def _aggregate_summary(
|
|||||||
|
|
||||||
|
|
||||||
async def _customer_names(session: AsyncSession, owner: str, talker_ids: list[str]) -> dict[str, tuple[str, str, str]]:
|
async def _customer_names(session: AsyncSession, owner: str, talker_ids: list[str]) -> dict[str, tuple[str, str, str]]:
|
||||||
|
"""客户名/头像解析:优先 customer_account_link per-owner 快照,档案兜底。
|
||||||
|
|
||||||
|
共享客户的档案行(customer 全局单行,last-wins)可能挂在别的员工名下,
|
||||||
|
按 owner 查档案会查空、明细列表回退显示 wxid(2026-09-09,同 sessions
|
||||||
|
._meta 修复)。
|
||||||
|
"""
|
||||||
if not talker_ids:
|
if not talker_ids:
|
||||||
return {}
|
return {}
|
||||||
|
info: dict[str, tuple[str, str, str]] = {}
|
||||||
|
link_stmt = sa.select(
|
||||||
|
CustomerAccountLink.friend_id,
|
||||||
|
CustomerAccountLink.friend_remark,
|
||||||
|
CustomerAccountLink.friend_nickname,
|
||||||
|
CustomerAccountLink.real_name,
|
||||||
|
CustomerAccountLink.head_image_url,
|
||||||
|
).where(
|
||||||
|
CustomerAccountLink.channel == "wechat",
|
||||||
|
CustomerAccountLink.owner_account_id == owner,
|
||||||
|
CustomerAccountLink.friend_id.in_(talker_ids),
|
||||||
|
)
|
||||||
|
for fid, remark, nick, real, head in (await session.execute(link_stmt)).all():
|
||||||
|
info[fid] = (remark or nick or real or fid, real or "", head or "")
|
||||||
|
missing = [t for t in talker_ids if t not in info]
|
||||||
|
if missing:
|
||||||
stmt = sa.select(
|
stmt = sa.select(
|
||||||
Customer.friend_id,
|
Customer.friend_id,
|
||||||
Customer.friend_remark,
|
Customer.friend_remark,
|
||||||
@@ -155,9 +177,8 @@ async def _customer_names(session: AsyncSession, owner: str, talker_ids: list[st
|
|||||||
).where(
|
).where(
|
||||||
Customer.channel == "wechat",
|
Customer.channel == "wechat",
|
||||||
Customer.owner_account_id == owner,
|
Customer.owner_account_id == owner,
|
||||||
Customer.friend_id.in_(talker_ids),
|
Customer.friend_id.in_(missing),
|
||||||
)
|
)
|
||||||
info: dict[str, tuple[str, str, str]] = {}
|
|
||||||
for fid, remark, nick, real, head in (await session.execute(stmt)).all():
|
for fid, remark, nick, real, head in (await session.execute(stmt)).all():
|
||||||
info[fid] = (remark or nick or real or fid, real or "", head or "")
|
info[fid] = (remark or nick or real or fid, real or "", head or "")
|
||||||
return info
|
return info
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import time
|
|||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import ChatGroup, Customer, Message
|
from app.db.models import ChatGroup, Customer, CustomerAccountLink, Message
|
||||||
|
|
||||||
OWNER = "wx_owner_s"
|
OWNER = "wx_owner_s"
|
||||||
CH = "wechat"
|
CH = "wechat"
|
||||||
@@ -45,6 +45,40 @@ async def test_sessions_friend_and_group(make_client):
|
|||||||
assert g["name"] == "群一号" and g["today_count"] == 1
|
assert g["name"] == "群一号" and g["today_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sessions_shared_friend_name_from_link(make_client):
|
||||||
|
"""共享好友昵称读 link 快照(2026-09-09):档案行挂在别的员工名下时,
|
||||||
|
会话列表此前回退显示 wxid;link 表每账号一行,回填后都有昵称。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def seed(session: AsyncSession) -> None:
|
||||||
|
session.add_all([
|
||||||
|
# OWNER 与共享好友 t_shared 有会话
|
||||||
|
Message(msg_id="sh1", channel=CH, owner_account_id=OWNER, talker_id="t_shared",
|
||||||
|
room_id=None, is_send=True, timestamp=_NOW, type="text", type_raw=1, text="x"),
|
||||||
|
# 档案行(last-wins)挂在别的员工名下——按 owner 查档案必然查空
|
||||||
|
Customer(friend_id="t_shared", channel=CH,
|
||||||
|
owner_account_id="wx_other", friend_nickname="A**郑"),
|
||||||
|
# OWNER 名下的 per-owner link 快照
|
||||||
|
CustomerAccountLink(channel=CH, owner_account_id=OWNER, friend_id="t_shared",
|
||||||
|
friend_nickname="A**郑", friend_remark=None),
|
||||||
|
])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/sessions", params={"owner_account_id": OWNER, "channel": CH})
|
||||||
|
assert r.status_code == 200
|
||||||
|
by = {s.get("talker_id"): s for s in r.json()["sessions"]}
|
||||||
|
assert by["t_shared"]["name"] == "A**郑"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sessions_name_falls_back_to_archive_without_link(make_client):
|
||||||
|
"""link 未覆盖的好友回退档案路径(空表过渡安全)。"""
|
||||||
|
client = await make_client(_seed)
|
||||||
|
r = await client.get("/api/sessions", params={"owner_account_id": OWNER, "channel": CH})
|
||||||
|
by = {s.get("talker_id"): s for s in r.json()["sessions"]}
|
||||||
|
assert by["t1"]["name"] == "Alice" # 档案行在 OWNER 名下
|
||||||
|
|
||||||
|
|
||||||
async def test_sessions_sorted_by_last_chat_time_desc(make_client):
|
async def test_sessions_sorted_by_last_chat_time_desc(make_client):
|
||||||
client = await make_client(_seed)
|
client = await make_client(_seed)
|
||||||
body = (await client.get("/api/sessions", params={"owner_account_id": OWNER})).json()
|
body = (await client.get("/api/sessions", params={"owner_account_id": OWNER})).json()
|
||||||
|
|||||||
Reference in New Issue
Block a user