2026-09-03 16:37:04 +08:00
|
|
|
|
"""Tests for the msg_daily_stats 汇总层(日粒度会话统计)。
|
|
|
|
|
|
|
2026-09-07 14:13:09 +08:00
|
|
|
|
四个被钉死的行为:
|
2026-09-03 16:37:04 +08:00
|
|
|
|
|
|
|
|
|
|
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 表
|
|
|
|
|
|
重建全部日行(夜间自愈/部署回填用),幂等。
|
2026-09-07 14:13:09 +08:00
|
|
|
|
4. **消息串口径**(2026-09-07 批1 首响/回复率):cust_threads/
|
|
|
|
|
|
threads_answered/fr_sum_ms 三列的串算法矩阵——见下方测试组。
|
2026-09-03 16:37:04 +08:00
|
|
|
|
|
|
|
|
|
|
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 14:13:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 消息串口径:首响/回复率三列(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)
|