Files
yunke/yunke-server/app/api/routers/sessions.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

215 lines
9.5 KiB
Python

"""GET /api/sessions — 账号会话列表(好友+群,今日消息数+最近联系时间)。
**双路径(2026-09-03 会话列表超时修复)**:
- ``msg_daily_stats`` 有行 → 读日粒度汇总(每会话「今日行 + MAX(last_ts)」,
毫秒级;旧实时全量聚合在重账号上实测 3~5s,128MB buffer pool 下更被推过
前端 15s 超时)。
- 空表(部署回填前守卫)→ 回退旧实时聚合,行为不变。
today_start 钉死 Asia/Shanghai(UTC+8)当日 0 点,不依赖服务器时区;汇总层
``day_key`` 同口径。
"""
from __future__ import annotations
from collections import namedtuple
from datetime import datetime
from typing import Annotated
from zoneinfo import ZoneInfo
import sqlalchemy as sa
from fastapi import APIRouter, Depends, Query
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,
CustomerAccountLink,
Message,
MsgDailyStats,
)
from app.storage.summary import day_key, has_rows as _summary_has_rows
router = APIRouter(prefix="/api", tags=["sessions"])
SessionDep = Annotated[AsyncSession, Depends(get_session)]
_TZ = ZoneInfo("Asia/Shanghai")
def _today_start_ms() -> int:
"""Asia/Shanghai 当日 0 点 ms-epoch(部署无关、测试确定性)。"""
midnight = datetime.now(_TZ).replace(hour=0, minute=0, second=0, microsecond=0)
return int(midnight.timestamp() * 1000)
def _base(msg, owner: str, channel: str):
return [
msg.channel == channel,
msg.owner_account_id == owner,
msg.timestamp.is_not(None),
]
async def _friend_rows(session: AsyncSession, owner: str, channel: str, today_start: int):
stmt = (
sa.select(
Message.talker_id,
sa.func.max(Message.timestamp).label("last_chat_time"),
sa.func.sum(sa.case((Message.timestamp >= today_start, 1), else_=0)).label("today_count"),
)
.where(*_base(Message, owner, channel), sa.or_(Message.room_id.is_(None), Message.room_id == ""), Message.talker_id.is_not(None))
.group_by(Message.talker_id)
)
return (await session.execute(stmt)).all()
async def _group_rows(session: AsyncSession, owner: str, channel: str, today_start: int):
stmt = (
sa.select(
Message.room_id,
sa.func.max(Message.timestamp).label("last_chat_time"),
sa.func.sum(sa.case((Message.timestamp >= today_start, 1), else_=0)).label("today_count"),
)
.where(*_base(Message, owner, channel), Message.room_id.is_not(None), Message.room_id != "")
.group_by(Message.room_id)
)
return (await session.execute(stmt)).all()
# ── 汇总路径:msg_daily_stats 日行(namedtuple 与旧 Row 同形,端点主体零改动)── #
_FriendRow = namedtuple("_FriendRow", ["talker_id", "last_chat_time", "today_count"])
_GroupRow = namedtuple("_GroupRow", ["room_id", "last_chat_time", "today_count"])
async def _summary_rows(
session: AsyncSession, owner: str, channel: str, kind: str, today: int
) -> dict[str, tuple[int | None, int]]:
"""peer → (last_chat_time, today_count),来自日行聚合。
last = MAX(last_ts)(日行内时间单调,跨日最大即全史最后一条);
today = 当日行的 send+recv。
"""
base = (
MsgDailyStats.channel == channel,
MsgDailyStats.owner_account_id == owner,
MsgDailyStats.peer_kind == kind,
)
last = {
peer: last_ts
for peer, last_ts in (
await session.execute(
sa.select(MsgDailyStats.peer_id, sa.func.max(MsgDailyStats.last_ts))
.where(*base)
.group_by(MsgDailyStats.peer_id)
)
).all()
}
today_counts = {
peer: int(cnt or 0)
for peer, cnt in (
await session.execute(
sa.select(
MsgDailyStats.peer_id,
sa.func.coalesce(sa.func.sum(MsgDailyStats.send_cnt + MsgDailyStats.recv_cnt), 0),
)
.where(*base, MsgDailyStats.day == today)
.group_by(MsgDailyStats.peer_id)
)
).all()
}
return {peer: (last_ts, today_counts.get(peer, 0)) for peer, last_ts in last.items()}
async def _meta(session: AsyncSession, owner: str, channel: str, friend_ids: list[str], room_ids: list[str]):
"""查 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)``。
"""
friends: dict[str, tuple[str, str | None, int | None]] = {}
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(
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(
sa.select(ChatGroup.room_id, ChatGroup.room_name, ChatGroup.head_image_url)
.where(ChatGroup.channel == channel, ChatGroup.owner_account_id == owner, ChatGroup.room_id.in_(room_ids))
)).all():
groups[rid] = (name or rid, avatar)
return friends, groups
@router.get("/sessions")
async def list_sessions(
session: SessionDep,
owner_account_id: Annotated[str, Query(min_length=1)],
channel: Annotated[str, Query()] = "wechat",
is_del: Annotated[int | None, Query(ge=0, le=1)] = None,
) -> SessionListOut:
"""账号会话列表:好友(talker_id,room_id 空)+ 群(room_id),按 last_chat_time desc。
``is_del`` 可选过滤(对齐云客员工聊天窗口「删除好友」开关):按 customer.delete_flag
在 Python 侧过滤——0=未删(NULL 视为 0),1=已删;越界值(非 0/1)422 拒绝而非
静默空列表;无 customer 行的 talker(如 filehelper 等系统账号)视作未删保留;
群会话不受该过滤影响。不传时行为不变。
``pinned``:filehelper 会话置顶标记(对齐云客置顶区),其余好友/群恒 False。
"""
today_start = _today_start_ms()
if await _summary_has_rows(session):
f_map = await _summary_rows(session, owner_account_id, channel, "friend", day_key(today_start))
g_map = await _summary_rows(session, owner_account_id, channel, "room", day_key(today_start))
f_rows = [_FriendRow(talker_id=p, last_chat_time=t, today_count=c) for p, (t, c) in f_map.items()]
g_rows = [_GroupRow(room_id=p, last_chat_time=t, today_count=c) for p, (t, c) in g_map.items()]
else:
f_rows = await _friend_rows(session, owner_account_id, channel, today_start)
g_rows = await _group_rows(session, owner_account_id, channel, today_start)
fnames, gnames = await _meta(
session, owner_account_id, channel, [r.talker_id for r in f_rows], [r.room_id for r in g_rows]
)
sessions: list[SessionOut] = []
for r in f_rows:
name, avatar, delete_flag = fnames.get(r.talker_id, (r.talker_id, None, None))
if is_del is not None and (delete_flag or 0) != is_del:
continue
sessions.append(SessionOut(type="friend", talker_id=r.talker_id, name=name, avatar=avatar,
pinned=(r.talker_id == "filehelper"),
today_count=int(r.today_count or 0), last_chat_time=r.last_chat_time))
for r in g_rows:
name, avatar = gnames.get(r.room_id, (r.room_id, None))
sessions.append(SessionOut(type="group", room_id=r.room_id, name=name, avatar=avatar,
today_count=int(r.today_count or 0), last_chat_time=r.last_chat_time))
sessions.sort(key=lambda s: (s.last_chat_time is None, -(s.last_chat_time or 0)))
return SessionListOut(owner_account_id=owner_account_id, channel=channel, today_start=today_start, sessions=sessions)