feat(analytics): A3 流失预警三分法——真流失/从未激活/待交接

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-04 18:25:08 +08:00
parent 6790710965
commit 3b4254639d
3 changed files with 204 additions and 2 deletions
+105 -1
View File
@@ -14,7 +14,7 @@ from zoneinfo import ZoneInfo
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import MsgDailyStats, PhoneCall, StatDailyStaff
from app.db.models import Customer, MsgDailyStats, PhoneCall, Staff, StatDailyStaff
from app.storage.staff_summary import account_map
from app.storage.staff_summary import has_rows as _staff_rows
from app.storage.summary import day_bounds_ms
@@ -335,3 +335,107 @@ async def overview(
"trend": trend,
"anomaly": await anomaly(session, channel),
}
def _now_ms() -> int:
return int(dt.datetime.now(_SH).timestamp() * 1000)
def _next_tier(tier: int) -> int:
return {30: 60, 60: 90, 90: 10_000}[tier]
async def churn(
session: AsyncSession, *, channel: str, kind: str, tier: int,
min_msgs: int, page: int, page_size: int,
) -> dict:
"""A3 三分法(spec §3.3)。tier ∈ {30,60,90} 分层区间 [tier, next) ,90=≥90。"""
day_ms = 86_400_000
now = _now_ms()
base = [
Customer.channel == channel,
sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0),
]
if kind == "real":
lo = now - _next_tier(tier) * day_ms
hi = now - tier * day_ms
# last_chat_time ∈ (lo, hi]:沉默 tier..next_tier 天
base += [Customer.last_chat_time.is_not(None),
Customer.last_chat_time > lo,
Customer.last_chat_time <= hi]
# 历史消息 ≥ min_msgs 放 SQL 端(分页 total 语义正确):半连接聚合
base.append(
Customer.friend_id.in_(
sa.select(MsgDailyStats.peer_id)
.where(
MsgDailyStats.channel == channel,
MsgDailyStats.peer_kind == "friend",
)
.group_by(MsgDailyStats.peer_id)
.having(
sa.func.coalesce(
sa.func.sum(MsgDailyStats.send_cnt + MsgDailyStats.recv_cnt), 0
) >= min_msgs
)
)
)
order = Customer.last_chat_time.asc()
elif kind == "inactive":
base += [Customer.last_chat_time.is_(None)]
order = Customer.friend_id.asc()
elif kind == "handover":
base += [Customer.user_id.in_(
sa.select(Staff.user_id).where(Staff.is_on_job.is_(False))
)]
# MySQL 由 compat.py 编译补丁剥 NULLS LAST(DESC 下语义天然等价)
order = Customer.last_chat_time.desc().nulls_last()
else:
raise ValueError(f"bad kind: {kind!r}")
total = (
await session.execute(
sa.select(sa.func.count()).select_from(Customer).where(*base)
)
).scalar_one()
rows = (
await session.execute(
sa.select(Customer).where(*base).order_by(order)
.offset((page - 1) * page_size).limit(page_size)
)
).scalars().all()
# enrich:历史消息量/活跃天数(msg_daily_stats 按 peer 聚合)
items = []
if rows:
ids = [c.friend_id for c in rows]
agg = (
await session.execute(
sa.select(
MsgDailyStats.peer_id,
sa.func.coalesce(
sa.func.sum(MsgDailyStats.send_cnt + MsgDailyStats.recv_cnt), 0
),
sa.func.count(),
)
.where(
MsgDailyStats.channel == channel,
MsgDailyStats.peer_kind == "friend",
MsgDailyStats.peer_id.in_(ids),
)
.group_by(MsgDailyStats.peer_id)
)
).all()
hist = {p: (int(m), int(d)) for p, m, d in agg}
for c in rows:
m, d = hist.get(c.friend_id, (0, 0))
items.append({
"friend_id": c.friend_id,
"channel": c.channel,
"nickname": c.friend_nickname or c.friend_remark or c.friend_id,
"owner_account_id": c.owner_account_id,
"user_id": c.user_id,
"last_chat_time": c.last_chat_time,
"hist_msgs": m,
"active_days": d,
})
return {"items": items, "total": int(total), "page": page, "page_size": page_size}
+20 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.analytics import health as health_svc
@@ -36,3 +36,22 @@ async def analytics_overview(
与 entities[].last_seen 为 ISO 字符串的双格式)。
"""
return await metrics_svc.overview(session, channel=channel, scope=scope)
@router.get("/churn")
async def churn_list(
session: SessionDep,
channel: ChannelParam = "wechat",
type: Literal["real", "inactive", "handover"] = "real",
tier: int = Query(30, ge=30, le=90),
min_msgs: int = Query(5, ge=1, le=100),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
"""A3 流失预警名单(三分法)。tier 只认 30/60/90。"""
if tier not in (30, 60, 90):
raise HTTPException(422, "tier must be 30/60/90")
return await metrics_svc.churn(
session, channel=channel, kind=type, tier=tier,
min_msgs=min_msgs, page=page, page_size=page_size,
)
@@ -0,0 +1,79 @@
"""A3 三分法流失 + B2 该回未回。"""
from __future__ import annotations
from app.analytics import metrics
from app.db.models import Customer, MsgDailyStats, Staff
_NOW = 1756950000000 # 相对时间基准(绝对日期无关紧要,断言全部相对此值自洽)
def _cust(friend_id, *, last_chat=None, user_id="u1", delete=0, block=None):
return Customer(friend_id=friend_id, channel="wechat",
owner_account_id="wx1", user_id=user_id,
last_chat_time=last_chat, delete_flag=delete, is_block=block)
async def _seed_churn(session):
now = _NOW
day_ms = 86_400_000
session.add(Staff(user_id="u9", name="离职的", is_on_job=False))
session.add(Staff(user_id="u1", name="在职的", is_on_job=True))
# 真流失:45 天没聊 + 历史消息 ≥5
session.add(_cust("c_real", last_chat=now - 45 * day_ms))
# 历史消息不足 5 → 不算真流失
session.add(_cust("c_few", last_chat=now - 45 * day_ms))
# 只沉默 10 天 → 不入 30 档
session.add(_cust("c_recent", last_chat=now - 10 * day_ms))
# 从未激活
session.add(_cust("c_null", last_chat=None))
# 待交接:归属离职员工
session.add(_cust("c_hand", user_id="u9", last_chat=now - 3 * day_ms))
# 已删好友 → 全部剔除
session.add(_cust("c_del", last_chat=now - 45 * day_ms, delete=1))
# 历史消息量:real=6(≥5),few=2
for peer, n in (("c_real", 6), ("c_few", 2)):
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id=peer, day=20260601,
send_cnt=n, recv_cnt=0, last_ts=now - 50 * day_ms,
last_is_send=True))
await session.commit()
async def test_churn_real_tier30(make_client, monkeypatch):
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
async def seed(session):
await _seed_churn(session)
page = await metrics.churn(session, channel="wechat", kind="real",
tier=30, min_msgs=5, page=1, page_size=20)
ids = [r["friend_id"] for r in page["items"]]
assert "c_real" in ids and "c_few" not in ids
assert "c_recent" not in ids and "c_del" not in ids
real = next(r for r in page["items"] if r["friend_id"] == "c_real")
assert real["hist_msgs"] == 6 and real["active_days"] == 1
await make_client(seed)
async def test_churn_inactive_and_handover(make_client, monkeypatch):
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
async def seed(session):
await _seed_churn(session)
p1 = await metrics.churn(session, channel="wechat", kind="inactive",
tier=30, min_msgs=5, page=1, page_size=20)
assert {r["friend_id"] for r in p1["items"]} == {"c_null"}
p2 = await metrics.churn(session, channel="wechat", kind="handover",
tier=30, min_msgs=5, page=1, page_size=20)
assert {r["friend_id"] for r in p2["items"]} == {"c_hand"}
await make_client(seed)
async def test_churn_endpoint_422(make_client):
async def seed(session):
...
client = await make_client(seed)
r = await client.get("/api/analytics/churn", params={"tier": 45})
assert r.status_code == 422
r2 = await client.get("/api/analytics/churn", params={"channel": "sms"})
assert r2.status_code == 422