421998be54
根因(服务器实测):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>
186 lines
8.1 KiB
Python
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)
|