Files
yunke/yunke-server/tests/test_summary_daily_stats.py
T
浅念 feb3b98665 feat(analytics): 首响时长+回复率数据管道——消息串算法/日汇总扩展列/员工聚合
批1(A1+A2):管理层要看「每个人的响应质量」。

- 迁移 d9e0f1a2b3c4:msg_daily_stats/stat_daily_staff 各加
  cust_threads/threads_answered/fr_sum_ms(NOT NULL server_default 0,
  幂等守卫照 9b0d0c133035 惯例;models.py 双声明同步)
- summary.py _thread_stats:串=客户连续消息不被员工消息打断的段,
  is_send=None 不参与串判定;fr=员工首条回复−串末条客户消息;
  日切近似 v1(日末未闭合串计 threads 不计 answered,口径写进 docstring)
- recompute_days/recompute_all 都先按 ts 排序再算串(乱序防护)
- staff_summary 聚合三列按账号→员工累加(fr_cnt≡threads_answered 不单列)
- 测试 +10:串算法矩阵 9 例+staff 聚合传递;全量 589 绿
- 本地 86 万行回填验证:daily-stats 60s/staff 10s,rollup 总和与
  明细完全一致,随机 5 行 SQL 独立复算全对

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 14:13:09 +08:00

338 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 表
重建全部日行(夜间自愈/部署回填用),幂等。
4. **消息串口径**(2026-09-07 批1 首响/回复率):cust_threads/
threads_answered/fr_sum_ms 三列的串算法矩阵——见下方测试组。
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
# ── 消息串口径:首响/回复率三列(2026-09-07 批1)────────────────────────── #
#
# 口径(定案):串=客户连续消息(is_send=False)不被员工消息(is_send=True)
# 打断的段;is_send=None 不参与串判定(跳过,不打断也不起串)。每个被回串
# 一个样本 fr_ms=员工首条回复 ts−串末条客户消息 ts。日切近似:日末未闭合
# 串计 threads 不计 answered,跨夜的串在次日段重新起算。
async def _thread_rows(session):
rows = (await session.execute(select(MsgDailyStats))).scalars().all()
return {
(r.peer_kind, r.peer_id, r.day): (r.cust_threads, r.threads_answered, r.fr_sum_ms)
for r in rows
}
async def _seed_thread(session, msgs) -> dict:
"""写一批消息(走钩子→recompute_days),返回三列断言字典。"""
await upsert_messages(session, msgs)
await session.commit()
return await _thread_rows(session)
async def test_thread_single_answered(session):
"""单串被回:fr=回复−串首条客户消息(此处串只有一条)。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
],
)
# (cust_threads, threads_answered, fr_sum_ms)
assert rows[("friend", "t1", 20260903)] == (1, 1, 5 * 60 * 1000)
async def test_thread_fr_takes_last_customer_msg(session):
"""多条客户消息串内:fr 取「末条」客户消息(非首条)。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("c2", _sh(2026, 9, 3, 10, 2), is_send=False),
_msg("c3", _sh(2026, 9, 3, 10, 7), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 9)),
],
)
assert rows[("friend", "t1", 20260903)] == (1, 1, 2 * 60 * 1000) # 10:09−10:07
async def test_two_threads_only_one_answered(session):
"""两串只回一串:threads=2/answered=1,fr 只含被回串。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
_msg("c2", _sh(2026, 9, 3, 11, 0), is_send=False), # 新串,日末未闭合
],
)
assert rows[("friend", "t1", 20260903)] == (2, 1, 5 * 60 * 1000)
async def test_thread_not_broken_by_unknown(session):
"""unknown(is_send=None)消息夹在串中:不打断串,fr 仍取末条客户消息。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("u1", _sh(2026, 9, 3, 10, 1), is_send=None),
_msg("c2", _sh(2026, 9, 3, 10, 2), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
],
)
assert rows[("friend", "t1", 20260903)] == (1, 1, 3 * 60 * 1000) # 10:05−10:02
async def test_thread_open_at_day_end_counts_unanswered(session):
"""日末未闭合串:计 threads 不计 answered(日切近似,fr 不含它)。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
_msg("c2", _sh(2026, 9, 3, 23, 50), is_send=False), # 日末未回
],
)
assert rows[("friend", "t1", 20260903)] == (2, 1, 5 * 60 * 1000)
async def test_staff_msg_first_is_not_a_thread(session):
"""员工先发不算串:开场白式员工消息不产生 threads/answered。"""
rows = await _seed_thread(
session,
[
_msg("s0", _sh(2026, 9, 3, 9, 0)),
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
],
)
assert rows[("friend", "t1", 20260903)] == (1, 0, 0)
async def test_thread_closed_then_reopened_and_multi_fr(session):
"""闭合后再起串+多样本:fr_sum 累加各被回串的样本时长。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)), # 串1被回,fr=5min
_msg("s2", _sh(2026, 9, 3, 10, 8)), # 串已闭合,追加员工消息无效
_msg("c2", _sh(2026, 9, 3, 11, 0), is_send=False),
_msg("c3", _sh(2026, 9, 3, 11, 4), is_send=False),
_msg("s3", _sh(2026, 9, 3, 11, 10)), # 串2被回,fr=6min(11:10−11:04)
],
)
assert rows[("friend", "t1", 20260903)] == (2, 2, 11 * 60 * 1000)
async def test_thread_cross_day_restarts_next_day(session):
"""日切近似:23:50 的串在当日计 open;次日段客户续发+被回在次日重新起算。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 23, 50), is_send=False), # 当日未回
_msg("c2", _sh(2026, 9, 4, 9, 0), is_send=False), # 次日新串(同串跨夜近似)
_msg("s1", _sh(2026, 9, 4, 9, 3)),
],
)
assert rows[("friend", "t1", 20260903)] == (1, 0, 0)
assert rows[("friend", "t1", 20260904)] == (1, 1, 3 * 60 * 1000) # 9:03−9:00
async def test_recompute_all_carries_thread_stats(session):
"""全量重算路径(逐会话全史分桶)同样计算三值,且与钩子路径一致。"""
msgs = [
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("u1", _sh(2026, 9, 3, 10, 1), is_send=None),
_msg("c2", _sh(2026, 9, 3, 10, 2), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
_msg("c3", _sh(2026, 9, 3, 11, 0), is_send=False),
]
await upsert_messages(session, msgs)
await session.commit()
hooked = await _thread_rows(session)
await recompute_all(session)
assert await _thread_rows(session) == hooked
assert hooked[("friend", "t1", 20260903)] == (2, 1, 3 * 60 * 1000)