Files
yunke/yunke-server/tests/test_api_summary_paths.py
浅念 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

114 lines
5.6 KiB
Python

"""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()