Files
yunke/yunke-server/app/api/routers/sessions.py
T
浅念 421998be54 perf(api): 会话/5数字弹窗超时修复——日粒度汇总层 msg_daily_stats
根因(服务器实测):sessions 对 owner 全量 message GROUP BY,重账号 5.2万条
3~5s;staff-chat-stats 全时段窗 6.9s——128MB buffer pool 下被推过前端
15s 超时(ECONNABORTED)。EXPLAIN ANALYZE 证实最优 covering 索引也要
18.9s,瓶颈是磁盘不是查询形状。

修复(预聚合,云客同款思路):
- msg_daily_stats 表:每「渠道×账号×会话×上海自然日」一行(收发计数/
  当日最后时间与方向);迁移 a7d3e9f1c2b4(幂等建表)
- 写路径:repos.upsert_messages 落库自动重算本批触及的会话×天(所有
  抓取路径汇到该函数,一处挂全;失败不阻塞写入)
- 读路径:sessions/staff-chat-stats 表有行时读汇总(namedtuple 同形,
  端点主体零改动),空表守卫回退旧聚合(部署回填前不空白)
- 自愈:CLI daily-stats 全量重建(幂等);full_sync_all.sh 夜间追加第 11
  步——顺带修掉尾部 sqlite3 直连 smoke.db 的硬编码(服务器 MySQL 上会炸)
- 口径统一:day 一律 Asia/Shanghai Python 计算(旧 from_unixtime 跟随
  DB 会话时区与今日数割裂);统计窗口对齐自然日(文档化语义变化)

测试:12 新增(汇总层 8 + 端点判别/对拍 4,判别手法=回填后删光 message
仍出完整数据),全量 512 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 16:37:36 +08:00

186 lines
8.1 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, 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/chat_group 表合并昵称/头像;好友行额外带 delete_flag(is_del 过滤用)。
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(
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_(friend_ids))
)).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)