feat(analytics): /overview 端点+unmapped_accounts 标注字段

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-04 18:04:05 +08:00
parent ad153aa2f7
commit b067d690f1
3 changed files with 139 additions and 18 deletions
+31 -14
View File
@@ -136,27 +136,39 @@ async def _first_contact(
async def _active_staff(
session: AsyncSession, channel: str, days: list[int]
) -> int:
"""活跃员工 distinct user_id;stat_daily_staff 空表回退 msg_daily_stats+映射。"""
) -> tuple[int, int]:
"""活跃员工 (distinct 数, 未映射账号数);stat_daily_staff 空表回退 msg_daily_stats+映射。
未映射口径(spec §8「未映射账号按账号计并标注」):
- rollup:当窗 distinct user_id 不在 account_map 值集(=在册员工 user_id
全集)——recompute 以 amap.get(owner, owner) 落表,映射不上的 owner
以原值混入 user_id 列,值集之外的即未映射;
- 回退:当窗 owner 中 amap.get(o, o)==o(映射不上,按账号原值计入
活跃数)的计未映射——与 rollup 口径一致(映射不上的账号原样出现)。
"""
if await _staff_rows(session):
val = (
await session.execute(
sa.select(sa.func.count(sa.func.distinct(StatDailyStaff.user_id))).where(
StatDailyStaff.channel == channel,
StatDailyStaff.day.in_(days),
uids = set(
(
await session.execute(
sa.select(StatDailyStaff.user_id).where(
StatDailyStaff.channel == channel,
StatDailyStaff.day.in_(days),
)
)
)
).scalar_one()
return int(val)
).scalars()
)
mapped = set((await account_map(session, channel)).values())
return len(uids), len(uids - mapped)
amap = await account_map(session, channel)
owners = {amap.get(o, o) for (o,) in (
owners = {o for (o,) in (
await session.execute(
sa.select(MsgDailyStats.owner_account_id).where(
MsgDailyStats.channel == channel, MsgDailyStats.day.in_(days)
)
)
).all()}
return len(owners)
unmapped = sum(1 for o in owners if o not in amap)
return len({amap.get(o, o) for o in owners}), unmapped
def anomaly_threshold() -> float:
@@ -248,7 +260,10 @@ async def anomaly(session: AsyncSession, channel: str) -> dict | None:
async def overview(
session: AsyncSession, *, channel: str, scope: str, trend_days: int = 30
) -> dict[str, Any]:
"""A1:卡片 + 环比(仅周/月,同期对齐) + 近 N 天趋势 + 异常警示。"""
"""A1:卡片 + 环比(仅周/月,同期对齐) + 近 N 天趋势 + 异常警示。
cards.unmapped_accounts=活跃数里映射不上的账号数(spec §8 按账号计并标注)。
"""
if trend_days < 1 or trend_days > 90:
raise ValueError("trend_days must be 1..90")
today = today_key()
@@ -257,10 +272,12 @@ async def overview(
msg_total = sum(int(s or 0) + int(r or 0) for _d, _k, _p, s, r in rows)
active_customers = len({p for _d, kind, p, _s, _r in rows if kind == "friend"})
active_staff, unmapped = await _active_staff(session, channel, days)
cards: dict[str, Any] = {
"msg_total": msg_total,
"active_customers": active_customers,
"active_staff": await _active_staff(session, channel, days),
"active_staff": active_staff,
"unmapped_accounts": unmapped,
"call_cnt": await _call_count(session, days),
"first_contact": await _first_contact(session, channel, days),
}
+19 -2
View File
@@ -1,17 +1,20 @@
"""GET /api/analytics/* —— 数据分析板块端组(前缀分组=未来角色权限落点)。"""
from __future__ import annotations
from typing import Annotated, Any
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.analytics import health as health_svc
from app.analytics import metrics as metrics_svc
from app.api.deps import get_session
router = APIRouter(prefix="/api/analytics", tags=["analytics"])
SessionDep = Annotated[AsyncSession, Depends(get_session)]
ChannelParam = Annotated[Literal["wechat", "whatsapp"], Query()]
ScopeParam = Annotated[Literal["today", "week", "month"], Query()]
@router.get("/health")
@@ -19,3 +22,17 @@ 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)}
@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)