diff --git a/yunke-server/app/api/routers/export.py b/yunke-server/app/api/routers/export.py index b33534b..297ab17 100644 --- a/yunke-server/app/api/routers/export.py +++ b/yunke-server/app/api/routers/export.py @@ -19,7 +19,7 @@ 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, Message +from app.db.models import Customer, CustomerAccountLink, Message router = APIRouter(prefix="/api/export", tags=["export"]) @@ -209,22 +209,40 @@ async def export_chat_html_zip( status_code=422, detail=f"一次最多导出 {ZIP_MAX_CONVS} 个会话" ) - # 文件名显示名:批量取一次 Customer(备注 > 昵称 > friend_id) - cust_rows = (await session.execute( + # 文件名显示名:批量取一次(备注 > 昵称 > friend_id)。优先 + # customer_account_link per-owner 快照——共享客户的档案行可能挂在别的 + # 员工名下,按 owner 查档案会查空、zip 文件名回退成 wxid(2026-09-09, + # 同 sessions._meta 修复);link 没覆盖的回退档案。 + link_rows = (await session.execute( sa.select( - Customer.friend_id, - Customer.friend_nickname, - Customer.friend_remark, + CustomerAccountLink.friend_id, + CustomerAccountLink.friend_nickname, + CustomerAccountLink.friend_remark, ).where( - Customer.channel == channel, - Customer.owner_account_id == owner_account_id, - Customer.friend_id.in_(ids), + 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 cust_rows + 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: diff --git a/yunke-server/app/api/routers/sessions.py b/yunke-server/app/api/routers/sessions.py index ff90bec..e7868c0 100644 --- a/yunke-server/app/api/routers/sessions.py +++ b/yunke-server/app/api/routers/sessions.py @@ -22,7 +22,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_session 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 router = APIRouter(prefix="/api", tags=["sessions"]) @@ -117,7 +123,13 @@ async def _summary_rows( 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 值仍为 ``(name, avatar)``。 @@ -126,12 +138,29 @@ async def _meta(session: AsyncSession, owner: str, channel: str, friend_ids: lis if friend_ids: for fid, remark, nick, real, avatar, delete_flag in (await session.execute( sa.select( - Customer.friend_id, Customer.friend_remark, Customer.friend_nickname, - Customer.real_name, Customer.head_image_url, Customer.delete_flag, + 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), ) - .where(Customer.channel == channel, Customer.owner_account_id == owner, Customer.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( + sa.select( + Customer.friend_id, Customer.friend_remark, Customer.friend_nickname, + Customer.real_name, Customer.head_image_url, Customer.delete_flag, + ) + .where(Customer.channel == channel, Customer.owner_account_id == owner, Customer.friend_id.in_(missing)) + )).all(): + friends[fid] = (remark or nick or real or fid, avatar, delete_flag) groups: dict[str, tuple[str, str | None]] = {} if room_ids: for rid, name, avatar in (await session.execute( diff --git a/yunke-server/app/api/routers/staff_chat_stats.py b/yunke-server/app/api/routers/staff_chat_stats.py index a02b1f4..c426e44 100644 --- a/yunke-server/app/api/routers/staff_chat_stats.py +++ b/yunke-server/app/api/routers/staff_chat_stats.py @@ -20,7 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_session 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 router = APIRouter(prefix="/api", tags=["staff-chat-stats"]) @@ -144,22 +144,43 @@ async def _aggregate_summary( 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: return {} - stmt = sa.select( - Customer.friend_id, - Customer.friend_remark, - Customer.friend_nickname, - Customer.real_name, - Customer.head_image_url, - ).where( - Customer.channel == "wechat", - Customer.owner_account_id == owner, - Customer.friend_id.in_(talker_ids), - ) info: dict[str, tuple[str, str, str]] = {} - for fid, remark, nick, real, head in (await session.execute(stmt)).all(): + 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( + Customer.friend_id, + Customer.friend_remark, + Customer.friend_nickname, + Customer.real_name, + Customer.head_image_url, + ).where( + Customer.channel == "wechat", + Customer.owner_account_id == owner, + Customer.friend_id.in_(missing), + ) + 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 "") return info diff --git a/yunke-server/tests/test_api_sessions.py b/yunke-server/tests/test_api_sessions.py index 451ef34..673b220 100644 --- a/yunke-server/tests/test_api_sessions.py +++ b/yunke-server/tests/test_api_sessions.py @@ -5,7 +5,7 @@ import time 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" CH = "wechat" @@ -45,6 +45,40 @@ async def test_sessions_friend_and_group(make_client): 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): client = await make_client(_seed) body = (await client.get("/api/sessions", params={"owner_account_id": OWNER})).json()