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>
This commit is contained in:
浅念
2026-09-03 16:37:04 +08:00
parent e3f7aa1304
commit 421998be54
11 changed files with 892 additions and 32 deletions
+3
View File
@@ -26,3 +26,6 @@ media/
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
.playwright-mcp/ .playwright-mcp/
# 部署过程产生的 git bundle 临时包
*.bundle
+65 -6
View File
@@ -1,10 +1,17 @@
"""GET /api/sessions — 账号会话列表(好友+群,今日消息数+最近联系时间),全本地 message 聚合。 """GET /api/sessions — 账号会话列表(好友+群,今日消息数+最近联系时间)。
镜像 staff_chat_stats 聚合模式(GROUP BY + case 计数 + Python 侧合并昵称/头像)。 **双路径(2026-09-03 会话列表超时修复)**:
today_start 钉死 Asia/Shanghai(UTC+8)当日 0 点,不依赖服务器时区。 - ``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 __future__ import annotations
from collections import namedtuple
from datetime import datetime from datetime import datetime
from typing import Annotated from typing import Annotated
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -15,7 +22,8 @@ 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 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"]) router = APIRouter(prefix="/api", tags=["sessions"])
@@ -63,6 +71,51 @@ async def _group_rows(session: AsyncSession, owner: str, channel: str, today_sta
return (await session.execute(stmt)).all() 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]): 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/chat_group 表合并昵称/头像;好友行额外带 delete_flag(is_del 过滤用)。
@@ -105,8 +158,14 @@ async def list_sessions(
``pinned``:filehelper 会话置顶标记(对齐云客置顶区),其余好友/群恒 False。 ``pinned``:filehelper 会话置顶标记(对齐云客置顶区),其余好友/群恒 False。
""" """
today_start = _today_start_ms() today_start = _today_start_ms()
f_rows = await _friend_rows(session, owner_account_id, channel, today_start) if await _summary_has_rows(session):
g_rows = await _group_rows(session, owner_account_id, channel, today_start) 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( 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] session, owner_account_id, channel, [r.talker_id for r in f_rows], [r.room_id for r in g_rows]
) )
@@ -1,12 +1,17 @@
"""GET /api/staff-chat-stats — 微信员工 5 数字 + 客户明细(本地 message 表实时聚合)。 """GET /api/staff-chat-stats — 微信员工 5 数字 + 客户明细。
**双路径(2026-09-03 全时段窗 6.9s 超时修复)**:``msg_daily_stats`` 有行时
读日粒度汇总(窗口对齐到自然日,毫秒级);空表回退旧实时聚合,行为不变。
窗口语义随之统一为「上海自然日」(旧 SQL 日期函数跟随 DB 会话时区,UTC
容器下与今日数口径割裂——顺带修复)。
数据全本地:按 员工微信 wxid × ms-epoch 日期窗 聚合 1:1 私聊(room_id 过滤), 数据全本地:按 员工微信 wxid × ms-epoch 日期窗 聚合 1:1 私聊(room_id 过滤),
不访问云客。5 汇总数从 customers 在 Python 侧派生(spec §5)。 不访问云客。5 汇总数从 customers 在 Python 侧派生(spec §5)。
跨库日期提取(codebase 首例)镜像 app/storage/repos.py 的方言检测写法。
""" """
from __future__ import annotations from __future__ import annotations
import time import time
from collections import namedtuple
from typing import Annotated from typing import Annotated
import sqlalchemy as sa import sqlalchemy as sa
@@ -15,7 +20,8 @@ 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 from app.db.models import Customer, Message, MsgDailyStats
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"])
@@ -81,6 +87,62 @@ async def _aggregate(session: AsyncSession, owner: str, start: int, end: int) ->
return agg_rows, last_map return agg_rows, last_map
# ── 汇总路径:msg_daily_stats 日行(namedtuple 与旧 Row 同形)────────────── #
_AggRow = namedtuple(
"_AggRow", ["talker_id", "send_count", "receive_count", "interaction_days", "last_time"]
)
async def _aggregate_summary(
session: AsyncSession, owner: str, start: int, end: int
) -> tuple[list, dict[str, bool | None]]:
"""窗口 → [day_key(start), day_key(end)] 自然日区间,按日行 Python 聚合。
interaction_days = 窗内有消息的自然日数;last_time/last_is_send 取窗内
最后一条(last_ts 最大的日行)。窗口对齐自然日是文档化的语义变化
(前端日期选择器本就按天取值)。
"""
rows = (
await session.execute(
sa.select(
MsgDailyStats.peer_id,
MsgDailyStats.day,
MsgDailyStats.send_cnt,
MsgDailyStats.recv_cnt,
MsgDailyStats.last_ts,
MsgDailyStats.last_is_send,
).where(
MsgDailyStats.channel == "wechat",
MsgDailyStats.owner_account_id == owner,
MsgDailyStats.peer_kind == "friend",
MsgDailyStats.day >= day_key(start),
MsgDailyStats.day <= day_key(end),
)
)
).all()
per: dict[str, dict] = {}
for peer, _day, send, recv, last_ts, last_is in rows:
acc = per.setdefault(
peer, {"send": 0, "recv": 0, "days": 0, "last_ts": None, "last_is": None}
)
acc["send"] += int(send or 0)
acc["recv"] += int(recv or 0)
acc["days"] += 1
if acc["last_ts"] is None or (last_ts is not None and last_ts >= acc["last_ts"]):
acc["last_ts"] = last_ts
acc["last_is"] = last_is
agg_rows = [
_AggRow(talker_id=p, send_count=a["send"], receive_count=a["recv"],
interaction_days=a["days"], last_time=a["last_ts"])
for p, a in per.items()
]
last_map = {p: a["last_is"] for p, a in per.items()}
return agg_rows, last_map
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]]:
if not talker_ids: if not talker_ids:
return {} return {}
@@ -114,7 +176,10 @@ async def get_staff_chat_stats(
if start is None: if start is None:
start = end - 3 * _MS_PER_DAY # 默认近 3 天 start = end - 3 * _MS_PER_DAY # 默认近 3 天
agg_rows, last_map = await _aggregate(session, owner_account_id, start, end) if await _summary_has_rows(session):
agg_rows, last_map = await _aggregate_summary(session, owner_account_id, start, end)
else:
agg_rows, last_map = await _aggregate(session, owner_account_id, start, end)
talker_ids = [r.talker_id for r in agg_rows] talker_ids = [r.talker_id for r in agg_rows]
names = await _customer_names(session, owner_account_id, talker_ids) names = await _customer_names(session, owner_account_id, talker_ids)
+41
View File
@@ -740,6 +740,46 @@ class SyncRun(Base):
__table_args__ = (Index("ix_sync_run_entity_status", "entity_type", "status"),) __table_args__ = (Index("ix_sync_run_entity_status", "entity_type", "status"),)
class MsgDailyStats(Base):
"""日粒度会话统计:每个「渠道×账号×会话×自然日」一行(2026-09-03)。
sessions(staff-chat 会话列表)与 staff-chat-stats(5 数字弹窗)的预聚
合层——这两端点原先每次请求对 owner 全量 message 做 GROUP BY(重账号
5.2 万条实测 3~7s,128MB buffer pool 下更被推过前端 15s 超时)。日行由
写路径维护:``repos.upsert_messages`` 落库后自动重算本批触及的
「会话×天」;夜间 ``recompute_all`` 全量重建自愈(裸 UPDATE/回填脚本
等绕过钩子的写入由它兜底)。
口径:
- ``day`` 一律 Asia/Shanghai 自然日(Python 计算,不用 SQL 日期函数——
from_unixtime 跟随 MySQL 会话时区,UTC 容器下与今日数口径割裂)。
- ``send_cnt``/``recv_cnt`` 仅计 is_send 真/假;NULL 两边都不计(对齐
旧聚合的 case 语义)。
- ``last_ts``/``last_is_send`` 为该日最后一条消息的时间与方向。
"""
__tablename__ = "msg_daily_stats"
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
# friend = 1:1 私聊(peer=talker_id) / room = 群(peer=room_id)
peer_kind: Mapped[str] = mapped_column(String(8), primary_key=True)
peer_id: Mapped[str] = mapped_column(String(128), primary_key=True)
# yyyymmdd,如 20260903(上海自然日)
day: Mapped[int] = mapped_column(Integer, primary_key=True)
send_cnt: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
recv_cnt: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
# 当日最后一条消息(ms);当天无有效方向计数的消息也存在(is_send=NULL)
last_ts: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
last_is_send: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
updated_at: Mapped[dt.datetime] = _updated_at()
__all__ = [ __all__ = [
"ChatGroup", "ChatGroup",
"Customer", "Customer",
@@ -748,6 +788,7 @@ __all__ = [
"Media", "Media",
"Message", "Message",
"MomentsPost", "MomentsPost",
"MsgDailyStats",
"PhoneCall", "PhoneCall",
"Sms", "Sms",
"Staff", "Staff",
+18
View File
@@ -53,6 +53,7 @@ that one table. Multi-table routing is a LATER task (YAGNI for M1).
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any from typing import Any
import sqlalchemy as sa import sqlalchemy as sa
@@ -74,6 +75,9 @@ from app.db.models import (
WechatAccount, WechatAccount,
WhatsappAccount, WhatsappAccount,
) )
from app.storage.summary import recompute_days, touched_day_keys
_logger = logging.getLogger(__name__)
__all__ = [ __all__ = [
"upsert_chat_group", "upsert_chat_group",
@@ -409,6 +413,20 @@ async def upsert_messages(session: AsyncSession, recs: list[dict[str, Any]]) ->
""" """
for rec in recs: for rec in recs:
await _upsert_one(session, Message, rec) await _upsert_one(session, Message, rec)
# 落库后维护日粒度汇总(msg_daily_stats):只重算本批触及的「会话×天」,
# 窄时间范围查询毫秒级。所有消息写入路径(页面抓取器微信/WA/群、CLI
# 手动同步)都汇到本函数,一处挂全。失败不阻塞消息写入——夜间
# recompute_all 自愈兜底(2026-09-03 会话列表超时修复)。
keys = touched_day_keys(recs)
if keys:
try:
await recompute_days(session, keys)
except Exception:
_logger.exception(
"daily-stats recompute failed (%d keys); nightly rebuild will heal", len(keys)
)
return len(recs) return len(recs)
+245
View File
@@ -0,0 +1,245 @@
"""日粒度会话汇总(msg_daily_stats)的维护层。
设计要点(2026-09-03 会话列表超时修复):
- **day 口径**:Asia/Shanghai 自然日,:func:`day_key` 纯 Python 计算——
不用 SQL 日期函数(from_unixtime 跟随 MySQL 会话时区,UTC 容器下与
sessions 的上海零点口径割裂;SQLite/MySQL 方言也各自不同)。
- **写路径钩子**::func:`repos.upsert_messages` 落库后调
:func:`recompute_days` 重算本批触及的「会话×天」(窄时间范围查询,
毫秒级)。页面抓取器/WA/群消息/CLI 手动同步全部路径最终都走
upsert_messages,一处挂全。
- **全量重建**::func:`recompute_all` 清空后逐会话重算——夜间自愈
(02:00 full_sync_all.sh 追加一步)兼部署回填,幂等。
计数语义与旧实时聚合完全一致:is_send=True 计 send、False 计 recv、
NULL 两边不计;timestamp 为 NULL 的消息不参与统计。
"""
from __future__ import annotations
import datetime as dt
import logging
from zoneinfo import ZoneInfo
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Message, MsgDailyStats
logger = logging.getLogger(__name__)
_SH = ZoneInfo("Asia/Shanghai")
async def has_rows(session: AsyncSession) -> bool:
"""汇总表是否已有数据(读端点的空表守卫:部署回填前自动回退旧实时聚合)。"""
return bool(
(
await session.execute(
sa.select(sa.func.count()).select_from(MsgDailyStats)
)
).scalar_one()
)
def day_key(ts_ms: int) -> int:
"""ms-epoch → 上海自然日 yyyymmdd。"""
d = dt.datetime.fromtimestamp(ts_ms / 1000, tz=_SH)
return d.year * 10000 + d.month * 100 + d.day
def _day_bounds_ms(day: int) -> tuple[int, int]:
"""上海自然日 → [当日 00:00, 次日 00:00) 的 ms 左闭右开区间。"""
y, m, d = day // 10000, (day // 100) % 100, day % 100
start = dt.datetime(y, m, d, tzinfo=_SH)
return int(start.timestamp() * 1000), int(
(start + dt.timedelta(days=1)).timestamp() * 1000
)
def _kind_peer(rec: dict) -> tuple[str, str] | None:
"""消息记录 → (peer_kind, peer_id);room 优先(群消息按群归账)。"""
room = rec.get("room_id")
if room:
return ("room", str(room))
talker = rec.get("talker_id")
if talker:
return ("friend", str(talker))
return None
def touched_day_keys(
recs: list[dict],
) -> set[tuple[str, str, str, str, int]]:
"""从一批消息记录提取要重算的 (channel, owner, kind, peer, day) 集合。"""
out: set[tuple[str, str, str, str, int]] = set()
for rec in recs:
ts = rec.get("timestamp")
if not ts:
continue
kp = _kind_peer(rec)
if kp is None:
continue
out.add(
(
str(rec.get("channel") or ""),
str(rec.get("owner_account_id") or ""),
kp[0],
kp[1],
day_key(int(ts)),
)
)
return out
def _peer_filter(kind: str, peer: str) -> list:
"""kind/peer → message 表过滤条件(与 sessions/staff-chat-stats 口径一致)。"""
if kind == "room":
return [
Message.room_id == peer,
Message.room_id.is_not(None),
Message.room_id != "",
]
return [
Message.talker_id == peer,
sa.or_(Message.room_id.is_(None), Message.room_id == ""),
]
async def recompute_days(
session: AsyncSession,
keys: set[tuple[str, str, str, str, int]] | list[tuple[str, str, str, str, int]],
) -> int:
"""重算给定 (channel, owner, kind, peer, day) 组合的统计行。
幂等:先删旧行再按当前 message 真值重建(0 条消息的日子删行不留);
同批消息跨页重放自然收敛。查询是窄时间范围(单会话单日),毫秒级。
"""
n = 0
for channel, owner, kind, peer, day in sorted(keys):
lo, hi = _day_bounds_ms(day)
rows = (
await session.execute(
sa.select(Message.timestamp, Message.is_send).where(
Message.channel == channel,
Message.owner_account_id == owner,
*_peer_filter(kind, peer),
Message.timestamp.is_not(None),
Message.timestamp >= lo,
Message.timestamp < hi,
)
)
).all()
send = recv = 0
last_ts: int | None = None
last_is_send: bool | None = None
for ts, is_send in rows:
if is_send is True:
send += 1
elif is_send is False:
recv += 1
if last_ts is None or ts >= last_ts:
last_ts = int(ts)
last_is_send = is_send
await session.execute(
sa.delete(MsgDailyStats).where(
MsgDailyStats.channel == channel,
MsgDailyStats.owner_account_id == owner,
MsgDailyStats.peer_kind == kind,
MsgDailyStats.peer_id == peer,
MsgDailyStats.day == day,
)
)
if rows:
session.add(
MsgDailyStats(
channel=channel,
owner_account_id=owner,
peer_kind=kind,
peer_id=peer,
day=day,
send_cnt=send,
recv_cnt=recv,
last_ts=last_ts,
last_is_send=last_is_send,
)
)
n += 1
return n
async def recompute_all(session: AsyncSession) -> int:
"""全量重建 msg_daily_stats(夜间自愈 / 部署回填)。
清空后按会话分组拉取全历史 (timestamp, is_send) 在 Python 侧按上海日
分桶——一次清空保证绕过钩子的写入(裸 UPDATE/回填脚本)也能被纠正。
返回重算的会话数。夜间 02:00 窗口跑,全库 86 万行量级预计分钟级。
"""
await session.execute(sa.delete(MsgDailyStats))
pair_rows = (
await session.execute(
sa.select(
Message.channel,
Message.owner_account_id,
Message.room_id,
Message.talker_id,
)
.where(Message.timestamp.is_not(None))
.distinct()
)
).all()
# 四列 DISTINCT 后同一群会按成员 talker 出现多行 → 归并成 (channel,
# owner, kind, peer) 去重,否则同主键日行会插两遍(IntegrityError)。
pairs: set[tuple[str, str, str, str]] = set()
for channel, owner, room, talker in pair_rows:
if room:
pairs.add((channel, owner, "room", str(room)))
elif talker:
pairs.add((channel, owner, "friend", str(talker)))
total_pairs = 0
for channel, owner, kind, peer in sorted(pairs):
# 拉全会话消息按日重算(而非逐日窄查):全量重建时一次往返更省
rows = (
await session.execute(
sa.select(Message.timestamp, Message.is_send).where(
Message.channel == channel,
Message.owner_account_id == owner,
*_peer_filter(kind, peer),
)
)
).all()
days: dict[int, list] = {}
for ts, is_send in rows:
days.setdefault(day_key(int(ts)), []).append((ts, is_send))
for day, day_rows in days.items():
send = recv = 0
last_ts = None
last_is_send = None
for ts, is_send in day_rows:
if is_send is True:
send += 1
elif is_send is False:
recv += 1
if last_ts is None or ts >= last_ts:
last_ts = int(ts)
last_is_send = is_send
session.add(
MsgDailyStats(
channel=channel,
owner_account_id=owner,
peer_kind=kind,
peer_id=peer,
day=day,
send_cnt=send,
recv_cnt=recv,
last_ts=last_ts,
last_is_send=last_is_send,
)
)
total_pairs += 1
if total_pairs % 500 == 0:
await session.flush()
return total_pairs
+30
View File
@@ -89,6 +89,18 @@ def _build_parser() -> argparse.ArgumentParser:
help="Suppress per-run JSON progress lines (errors still logged).", help="Suppress per-run JSON progress lines (errors still logged).",
) )
# ``daily-stats`` ---------------------------------------------------------
p_stats = sub.add_parser(
"daily-stats",
help="Rebuild msg_daily_stats from the message table.",
description=(
"Full rebuild of the daily conversation-summary table "
"(msg_daily_stats). Used as the nightly self-heal step in "
"scripts/full_sync_all.sh and as the one-off deploy backfill "
"(see docs/DEPLOY.md). Idempotent."
),
)
# ``scheduler`` ----------------------------------------------------------- # ``scheduler`` -----------------------------------------------------------
p_sched = sub.add_parser( p_sched = sub.add_parser(
"scheduler", "scheduler",
@@ -274,6 +286,22 @@ def _print_sync_summary(entity: str, results: list[Any]) -> None:
print(line, flush=True) print(line, flush=True)
async def _cmd_daily_stats(args: argparse.Namespace) -> int:
"""全量重建 msg_daily_stats(夜间自愈 / 部署回填,幂等)。"""
settings = get_settings()
engine, session_maker = await _init_db(settings.db_url)
try:
from app.storage.summary import recompute_all
async with session_maker() as session:
pairs = await recompute_all(session)
await session.commit()
print(f"[daily-stats] rebuilt {pairs} conversation pairs")
return 0
finally:
await engine.dispose()
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
"""CLI entry point. Returns a process exit code.""" """CLI entry point. Returns a process exit code."""
# Configure logging so the scheduler's JSON line + the orchestrator's # Configure logging so the scheduler's JSON line + the orchestrator's
@@ -292,6 +320,8 @@ def main(argv: list[str] | None = None) -> int:
return asyncio.run(_cmd_sync(args)) return asyncio.run(_cmd_sync(args))
if args.command == "scheduler": if args.command == "scheduler":
return asyncio.run(_cmd_scheduler(args)) return asyncio.run(_cmd_scheduler(args))
if args.command == "daily-stats":
return asyncio.run(_cmd_daily_stats(args))
except KeyboardInterrupt: except KeyboardInterrupt:
# So `python -m app.sync.cli scheduler` exits cleanly on Ctrl-C. # So `python -m app.sync.cli scheduler` exits cleanly on Ctrl-C.
return 0 return 0
@@ -0,0 +1,77 @@
"""msg_daily_stats 日粒度会话汇总表(会话列表/5数字弹窗超时修复)
Revision ID: a7d3e9f1c2b4
Revises: c53f90b1e6a4
Create Date: 2026-09-03 17:00:00.000000
内容
----
sessions 与 staff-chat-stats 原先每次请求对 owner 全量 message 实时
GROUP BY——重账号 5.2 万条消息实测 3~5s(sessions)/6.9s(全时段
staff-chat-stats),在服务器 128MB buffer pool 下进一步被推过前端 axios
15s 超时(ECONNABORTED,2026-09-03 用户报「聊天记录页数据多时请求失败」)。
本表是预聚合层:每个「渠道×账号×会话×上海自然日」一行(收/发计数、当日
最后一条时间与方向)。维护::
* 写路径:``repos.upsert_messages`` 落库后自动重算本批触及的「会话×天」
(所有抓取路径——页面抓取器微信/WA/群、CLI 手动同步——都汇到该函数)。
* 读路径:两个端点在表有行时读它(毫秒级),空表回退旧实时聚合(部署
回填前的守卫)。
* 自愈:夜间 full_sync_all.sh 的 ``daily-stats`` 步骤全量重建,纠正绕过
钩子的写入(裸 UPDATE/回填脚本);部署回填也用同一条命令
(``python -m app.sync.cli daily-stats``)。
day 为 int yyyymmdd,口径一律 Asia/Shanghai(Python 计算,不用 SQL 日期
函数——from_unixtime 跟随 DB 会话时区,与今日数口径割裂)。
幂等:``sa.inspect`` 先查同名表,存在即跳过。SQLite/MySQL 通用。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a7d3e9f1c2b4"
down_revision: str | Sequence[str] | None = "c53f90b1e6a4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TABLE = "msg_daily_stats"
def upgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
if insp.has_table(_TABLE):
return
op.create_table(
_TABLE,
sa.Column("channel", sa.String(length=16), primary_key=True),
sa.Column("owner_account_id", sa.String(length=128), primary_key=True),
sa.Column("peer_kind", sa.String(length=8), primary_key=True),
sa.Column("peer_id", sa.String(length=128), primary_key=True),
sa.Column("day", sa.Integer(), primary_key=True),
sa.Column(
"send_cnt", sa.Integer(), nullable=False, server_default=sa.text("0")
),
sa.Column(
"recv_cnt", sa.Integer(), nullable=False, server_default=sa.text("0")
),
sa.Column("last_ts", sa.BigInteger(), nullable=True),
sa.Column("last_is_send", sa.Boolean(), nullable=True),
sa.Column(
"updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()
),
)
def downgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
if not insp.has_table(_TABLE):
return
op.drop_table(_TABLE)
+46 -22
View File
@@ -45,30 +45,54 @@ echo "$(date) --- 9c/10 Moments (员工朋友圈) ---"
$PY scripts/scrape_via_page.py --entity moments $PY scripts/scrape_via_page.py --entity moments
echo "$(date) --- 10/10 Media download (audio+images) ---" echo "$(date) --- 10/10 Media download (audio+images) ---"
$PY scripts/scrape_via_page.py --entity media $PY scripts/scrape_via_page.py --entity media
# 11:会话日汇总全量重建(夜间自愈:绕过 upsert 钩子的写入由它纠正;
# sessions/staff-chat-stats 读路径依赖此表,2026-09-03)
echo "$(date) --- 11/11 Daily-stats rebuild ---"
$PY -m app.sync.cli daily-stats
echo "==============================================" echo "=============================================="
echo "$(date) === FULL RE-SYNC COMPLETE ===" echo "$(date) === FULL RE-SYNC COMPLETE ==="
echo "==============================================" echo "=============================================="
$PY -c " # 终局数据快照:走 app 引擎(DB_URL 可移植,SQLite/MySQL 通吃;原 sqlite3
import sqlite3 # 直连 smoke.db 的写法在服务器 MySQL 上会炸,2026-09-03 修)
con = sqlite3.connect('smoke.db') $PY - <<'PYEOF'
c = con.cursor() import asyncio
print()
print('=== FINAL DATA SUMMARY ===') from sqlalchemy import text
for label, sql in [ from sqlalchemy.ext.asyncio import create_async_engine
('wechat accounts', \"SELECT COUNT(*) FROM wechat_account\"),
('whatsapp accounts', \"SELECT COUNT(*) FROM whatsapp_account\"), from app.config import get_settings
('wechat customers', \"SELECT COUNT(*) FROM customer WHERE channel='wechat'\"),
('whatsapp customers', \"SELECT COUNT(*) FROM customer WHERE channel='whatsapp'\"), TABLES = [
('wechat groups', \"SELECT COUNT(*) FROM chat_group WHERE channel='wechat'\"), ("wechat accounts", "SELECT COUNT(*) FROM wechat_account"),
('whatsapp groups', \"SELECT COUNT(*) FROM chat_group WHERE channel='whatsapp'\"), ("whatsapp accounts", "SELECT COUNT(*) FROM whatsapp_account"),
('wechat messages', \"SELECT COUNT(*) FROM message WHERE channel='wechat'\"), ("wechat customers", "SELECT COUNT(*) FROM customer WHERE channel='wechat'"),
('whatsapp messages', \"SELECT COUNT(*) FROM message WHERE channel='whatsapp'\"), ("whatsapp customers", "SELECT COUNT(*) FROM customer WHERE channel='whatsapp'"),
('voice calls', \"SELECT COUNT(*) FROM phone_call WHERE channel IN ('voice_call','wa_voice_call')\"), ("wechat groups", "SELECT COUNT(*) FROM chat_group WHERE channel='wechat'"),
('voice clips', 'SELECT COUNT(*) FROM voice_clip'), ("whatsapp groups", "SELECT COUNT(*) FROM chat_group WHERE channel='whatsapp'"),
('moments posts', 'SELECT COUNT(*) FROM moments_post'), ("wechat messages", "SELECT COUNT(*) FROM message WHERE channel='wechat'"),
('media files', 'SELECT COUNT(*) FROM media'), ("whatsapp messages", "SELECT COUNT(*) FROM message WHERE channel='whatsapp'"),
]: ("daily stats rows", "SELECT COUNT(*) FROM msg_daily_stats"),
print(f' {label:22s} {c.execute(sql).fetchone()[0]:>6}') ("voice calls", "SELECT COUNT(*) FROM phone_call WHERE channel IN ('voice_call','wa_voice_call')"),
" ("voice clips", "SELECT COUNT(*) FROM voice_clip"),
("moments posts", "SELECT COUNT(*) FROM moments_post"),
("media files", "SELECT COUNT(*) FROM media"),
]
async def main() -> None:
engine = create_async_engine(get_settings().db_url)
try:
async with engine.connect() as conn:
print()
print("=== FINAL DATA SUMMARY ===")
for label, sql in TABLES:
n = (await conn.execute(text(sql))).scalar_one()
print(f" {label:22s} {n:>6}")
finally:
await engine.dispose()
asyncio.run(main())
PYEOF
@@ -0,0 +1,113 @@
"""sessions / staff-chat-stats 的汇总表读路径判别测试(2026-09-03)。
两个端点在 msg_daily_stats 有行时必须改读汇总表(毫秒级),空表时回退旧
实时聚合(部署回填前的守卫)。判别手法:**回填汇总后把 message 表删光**——
旧实现会返回空,只有真正读汇总表的实现还能吐出完整数据。
另配对拍测试:同一批种子,「无汇总(旧路径)」与「有汇总(新路径)」的
响应必须逐字段一致。
"""
from __future__ import annotations
import time
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import ChatGroup, Customer, Message
from app.storage.summary import recompute_all
OWNER = "wx_owner_sum"
CH = "wechat"
_NOW = int(time.time() * 1000)
_MS_DAY = 86_400_000
async def _seed(session: AsyncSession) -> None:
session.add_all([
# t1:今天 1 发 1 收,2 天前还有一条
Message(msg_id="s1", channel=CH, owner_account_id=OWNER, talker_id="t1", room_id=None, is_send=True, timestamp=_NOW, type="text", type_raw=1, text="x"),
Message(msg_id="s2", channel=CH, owner_account_id=OWNER, talker_id="t1", room_id=None, is_send=False, timestamp=_NOW - 1000, type="text", type_raw=1, text="y"),
Message(msg_id="s3", channel=CH, owner_account_id=OWNER, talker_id="t1", room_id=None, is_send=False, timestamp=_NOW - 2 * _MS_DAY, type="text", type_raw=1, text="old"),
# t2:只有 5 天前的消息(今日 0)
Message(msg_id="s4", channel=CH, owner_account_id=OWNER, talker_id="t2", room_id=None, is_send=True, timestamp=_NOW - 5 * _MS_DAY, type="text", type_raw=1, text="o2"),
# 群:今天 2 条
Message(msg_id="s5", channel=CH, owner_account_id=OWNER, talker_id="m1", room_id="room1@chatroom", is_send=True, timestamp=_NOW, type="text", type_raw=1, text="g1"),
Message(msg_id="s6", channel=CH, owner_account_id=OWNER, talker_id="m2", room_id="room1@chatroom", is_send=False, timestamp=_NOW - 2000, type="text", type_raw=1, text="g2"),
Customer(friend_id="t1", channel=CH, owner_account_id=OWNER, friend_nickname="Alice", friend_remark=None),
Customer(friend_id="t2", channel=CH, owner_account_id=OWNER, friend_nickname="Bob", friend_remark=None),
ChatGroup(room_id="room1@chatroom", channel=CH, owner_account_id=OWNER, room_name="群一号"),
])
await session.commit()
async def _seed_with_stats(session: AsyncSession) -> None:
await _seed(session)
await recompute_all(session)
await session.commit()
async def _stats_then_wipe(session: AsyncSession) -> None:
await _seed_with_stats(session)
await session.execute(sa.delete(Message))
await session.commit()
# ── sessions ─────────────────────────────────────────────────────────────── #
async def test_sessions_served_from_summary_when_populated(make_client):
"""回填后删光 message:响应仍完整 → 数据来自汇总表(旧实现此刻返回空)。"""
wiped = await make_client(_stats_then_wipe)
r = await wiped.get("/api/sessions", params={"owner_account_id": OWNER, "channel": CH})
assert r.status_code == 200
body = r.json()
by = {(s["type"], s.get("talker_id") or s.get("room_id")): s for s in body["sessions"]}
assert set(by) == {("friend", "t1"), ("friend", "t2"), ("group", "room1@chatroom")}
assert by[("friend", "t1")]["today_count"] == 2
assert by[("friend", "t1")]["last_chat_time"] == _NOW
assert by[("friend", "t2")]["today_count"] == 0
assert by[("group", "room1@chatroom")]["today_count"] == 2
assert by[("friend", "t1")]["name"] == "Alice"
async def test_sessions_parity_and_legacy_guard(make_client):
"""对拍:无汇总(守卫回退旧路径)响应 == 有汇总(新路径)响应,且逐字段一致。"""
legacy = await make_client(_seed)
modern = await make_client(_seed_with_stats)
params = {"owner_account_id": OWNER, "channel": CH}
r1 = await legacy.get("/api/sessions", params=params)
r2 = await modern.get("/api/sessions", params=params)
assert r1.status_code == r2.status_code == 200
assert r1.json() == r2.json()
# ── staff-chat-stats ─────────────────────────────────────────────────────── #
async def test_staff_chat_stats_served_from_summary(make_client):
"""全时段窗口(旧实现实测 6.9s 的场景):回填后删光 message 仍出全量数字。"""
client = await make_client(_stats_then_wipe)
r = await client.get(
"/api/staff-chat-stats",
params={"owner_account_id": OWNER, "start": 0, "end": _NOW + _MS_DAY},
)
assert r.status_code == 200
body = r.json()
by = {c["talker_id"]: c for c in body["customers"]}
assert set(by) == {"t1", "t2"} # 私聊口径,群不计
assert by["t1"]["send_count"] == 1 and by["t1"]["receive_count"] == 2
assert by["t1"]["last_time"] == _NOW
assert by["t2"]["send_count"] == 1 and by["t2"]["receive_count"] == 0
async def test_staff_chat_stats_parity(make_client):
"""宽窗口:旧路径与汇总路径响应一致。"""
legacy = await make_client(_seed)
modern = await make_client(_seed_with_stats)
params = {"owner_account_id": OWNER, "start": 0, "end": _NOW + _MS_DAY}
r1 = await legacy.get("/api/staff-chat-stats", params=params)
r2 = await modern.get("/api/staff-chat-stats", params=params)
assert r1.status_code == r2.status_code == 200
assert r1.json() == r2.json()
@@ -0,0 +1,185 @@
"""Tests for the msg_daily_stats 汇总层(日粒度会话统计)。
三个被钉死的行为:
1. **日桶口径**:day 一律 Asia/Shanghai 自然日(Python 计算,不用 SQL 日期
函数——旧实现 from_unixtime 跟随 MySQL 会话时区,UTC 容器下和今日数
的上海口径不一致,这是顺带修复的旧病)。
2. **写路径钩子**::func:`app.storage.repos.upsert_messages` 落库后自动
重算本批触及的「会话×天」行——页面抓取器/CLI 手动同步全部路径共用此
入口,一处挂全。幂等:同一批重放不产生重复/漂移。
3. **全量重算**::func:`app.storage.summary.recompute_all` 从 message 表
重建全部日行(夜间自愈/部署回填用),幂等。
fixtures 用内存 SQLite(测试对 message 的所有写入都走 upsert_messages,
从而同时覆盖钩子行为)。
"""
from __future__ import annotations
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.db.models import Message, MsgDailyStats
from app.storage.repos import upsert_messages
from app.storage.summary import day_key, recompute_all
_SH = ZoneInfo("Asia/Shanghai")
def _sh(y, m, d, h, mi=0, s=0) -> int:
return int(
datetime(y, m, d, h, mi, s, tzinfo=_SH).timestamp() * 1000
)
def _utc(y, m, d, h, mi=0, s=0) -> int:
return int(
datetime(y, m, d, h, mi, s, tzinfo=timezone.utc).timestamp() * 1000
)
@pytest.fixture
async def session():
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as s:
yield s
await engine.dispose()
# ── day_key:上海自然日 ──────────────────────────────────────────────────── #
def test_day_key_shanghai_calendar_day():
# 上海 2026-09-03 09:30 → 20260903
assert day_key(_sh(2026, 9, 3, 9, 30)) == 20260903
def test_day_key_uses_shanghai_not_utc():
# UTC 2026-09-02 16:30 = 上海 2026-09-03 00:30 → 必须归入 0903(旧
# from_unixtime 在 UTC 容器里会归入 0902——口径修复点)
assert day_key(_utc(2026, 9, 2, 16, 30)) == 20260903
def test_day_key_midnight_boundary():
# 上海零点整属于新的一天;前一天 23:59:59 属于前一天
assert day_key(_sh(2026, 9, 3, 0, 0, 0)) == 20260903
assert day_key(_sh(2026, 9, 2, 23, 59, 59)) == 20260902
# ── 写路径钩子:upsert_messages 自动维护日行 ────────────────────────────── #
def _msg(msg_id, ts, *, owner="wx_a", talker="t1", room=None, is_send=True):
return dict(
msg_id=msg_id,
channel="wechat",
owner_account_id=owner,
talker_id=talker if room is None else "room_member",
room_id=room,
is_send=is_send,
timestamp=ts,
type="text",
type_raw=1,
text="x",
)
async def _stats_rows(session):
rows = (await session.execute(select(MsgDailyStats))).scalars().all()
return {
(r.peer_kind, r.peer_id, r.day): (r.send_cnt, r.recv_cnt, r.last_ts, r.last_is_send)
for r in rows
}
async def test_upsert_messages_maintains_daily_rows(session):
await upsert_messages(
session,
[
_msg("m1", _sh(2026, 9, 3, 10, 0), is_send=True),
_msg("m2", _sh(2026, 9, 3, 11, 0), is_send=False),
_msg("m3", _sh(2026, 9, 3, 11, 30), is_send=True),
],
)
await session.commit()
rows = await _stats_rows(session)
assert set(rows) == {("friend", "t1", 20260903)}
assert rows[("friend", "t1", 20260903)] == (2, 1, _sh(2026, 9, 3, 11, 30), True)
async def test_upsert_messages_splits_days_and_kinds(session):
await upsert_messages(
session,
[
_msg("d1", _sh(2026, 9, 2, 23, 59), talker="t1"),
_msg("d2", _sh(2026, 9, 3, 0, 1), talker="t1"),
_msg("g1", _sh(2026, 9, 3, 9, 0), room="room1@chatroom", is_send=None),
],
)
await session.commit()
rows = await _stats_rows(session)
# 跨上海零点的两条消息分属两天;群消息单独一行(room 维度)
assert set(rows) == {
("friend", "t1", 20260902),
("friend", "t1", 20260903),
("room", "room1@chatroom", 20260903),
}
# is_send=NULL 的消息:两个方向都不计(对齐旧聚合 case 语义),但占用行
assert rows[("room", "room1@chatroom", 20260903)] == (0, 0, _sh(2026, 9, 3, 9, 0), None)
async def test_upsert_messages_idempotent(session):
batch = [
_msg("m1", _sh(2026, 9, 3, 10, 0)),
_msg("m2", _sh(2026, 9, 3, 11, 0), is_send=False),
]
await upsert_messages(session, batch)
await session.commit()
await upsert_messages(session, batch) # 同批重放(跨页重复是常态)
await session.commit()
rows = await _stats_rows(session)
assert len(rows) == 1
assert rows[("friend", "t1", 20260903)] == (1, 1, _sh(2026, 9, 3, 11, 0), False)
async def test_upsert_messages_null_timestamp_skipped(session):
await upsert_messages(session, [_msg("n1", None)])
await session.commit()
assert await _stats_rows(session) == {}
# ── 全量重算(夜间自愈/部署回填)────────────────────────────────────────── #
async def test_recompute_all_rebuilds_and_is_idempotent(session):
# 先写一批消息(顺带产生日行),再篡改一条日行模拟漂移,全量重算应修复
await upsert_messages(
session,
[_msg("m1", _sh(2026, 9, 3, 10, 0)), _msg("m2", _sh(2026, 9, 3, 11, 0), is_send=False)],
)
await session.commit()
bad = (await session.execute(select(MsgDailyStats))).scalars().one()
bad.send_cnt = 999 # 漂移
await session.commit()
n = await recompute_all(session)
assert n >= 1
rows = await _stats_rows(session)
assert rows[("friend", "t1", 20260903)] == (1, 1, _sh(2026, 9, 3, 11, 0), False)
await recompute_all(session) # 幂等(重建后是全新 ORM 对象,比字段值不比对象)
assert await _stats_rows(session) == rows