2026-09-04 15:37:13 +08:00
|
|
|
"""GET /api/analytics/* —— 数据分析板块端组(前缀分组=未来角色权限落点)。"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-09-04 18:04:05 +08:00
|
|
|
from typing import Annotated, Any, Literal
|
2026-09-04 15:37:13 +08:00
|
|
|
|
2026-09-04 18:25:08 +08:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
2026-09-04 15:37:13 +08:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.analytics import health as health_svc
|
2026-09-04 18:04:05 +08:00
|
|
|
from app.analytics import metrics as metrics_svc
|
2026-09-04 15:37:13 +08:00
|
|
|
from app.api.deps import get_session
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/analytics", tags=["analytics"])
|
|
|
|
|
|
|
|
|
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
2026-09-04 18:04:05 +08:00
|
|
|
ChannelParam = Annotated[Literal["wechat", "whatsapp"], Query()]
|
|
|
|
|
ScopeParam = Annotated[Literal["today", "week", "month"], Query()]
|
2026-09-04 15:37:13 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/health")
|
|
|
|
|
async def analytics_health(session: SessionDep) -> dict[str, Any]:
|
|
|
|
|
"""E0-lite:数据截至 + 实体新鲜度。"""
|
|
|
|
|
as_of = await health_svc.data_as_of(session)
|
|
|
|
|
return {"data_as_of": as_of, "entities": await health_svc.entity_freshness(session)}
|
2026-09-04 18:04:05 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/overview")
|
|
|
|
|
async def analytics_overview(
|
|
|
|
|
session: SessionDep,
|
|
|
|
|
channel: ChannelParam = "wechat",
|
|
|
|
|
scope: ScopeParam = "today",
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""A1 经营总览:卡片+趋势+异常警示一次返回(单往返)。
|
|
|
|
|
|
|
|
|
|
cards 不含 data_as_of——数据截至走 /health(data_as_of 为 ms-epoch int
|
|
|
|
|
与 entities[].last_seen 为 ISO 字符串的双格式)。
|
|
|
|
|
"""
|
|
|
|
|
return await metrics_svc.overview(session, channel=channel, scope=scope)
|
2026-09-04 18:25:08 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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,
|
|
|
|
|
)
|