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,
)