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}