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>
This commit is contained in:
浅念
2026-09-07 14:13:09 +08:00
parent 39791b2a49
commit feb3b98665
6 changed files with 359 additions and 5 deletions
+21 -2
View File
@@ -89,6 +89,10 @@ async def recompute_days(
MsgDailyStats.peer_id,
MsgDailyStats.send_cnt,
MsgDailyStats.recv_cnt,
# 首响/回复率三列(2026-09-07 批1):同义聚合累加
MsgDailyStats.cust_threads,
MsgDailyStats.threads_answered,
MsgDailyStats.fr_sum_ms,
).where(
MsgDailyStats.day == day, MsgDailyStats.channel == channel
)
@@ -102,13 +106,25 @@ async def recompute_days(
if not rows:
continue
agg: dict[str, dict] = {}
for owner, kind, peer, s, r in rows:
for owner, kind, peer, s, r, t, a_ns, fr in rows:
uid = amap.get(owner, owner) # 映射不上按 owner 原值(读侧标注)
a = agg.setdefault(
uid, {"sent": 0, "recv": 0, "friends": set(), "rooms": set()}
uid,
{
"sent": 0,
"recv": 0,
"friends": set(),
"rooms": set(),
"threads": 0,
"answered": 0,
"fr": 0,
},
)
a["sent"] += int(s or 0)
a["recv"] += int(r or 0)
a["threads"] += int(t or 0)
a["answered"] += int(a_ns or 0)
a["fr"] += int(fr or 0)
if kind == "friend": # 显式分支:未来新 kind 跳过,不误入 rooms
a["friends"].add(peer)
elif kind == "room":
@@ -123,6 +139,9 @@ async def recompute_days(
msg_recv=a["recv"],
active_friends=len(a["friends"]),
active_rooms=len(a["rooms"]),
cust_threads=a["threads"],
threads_answered=a["answered"],
fr_sum_ms=a["fr"],
)
)
n += 1