Files
meida_backend/apps/admin/routers/stats.py
T

159 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""行为统计:原始事件流 + 按天 × event_name 的 PV 透视(FR-ADM 行为分析能力)。
只读透视,直接走 ``ob_event``(ix_ob_event_user_ts / ix_ob_event_name_ts 两个
索引支撑这里的过滤与排序)。日桶统一 UTC+8 自然日(与 billing 的日预算桶、
sms/email 频控桶同一时区纪律),在 SQL 里 ``+ interval '8 hours'`` 后
``date_trunc``——不把整表捞回来再按 Python 侧分桶。
ob_event 数据量会先于其他表膨胀:所有端点强制分页(page_size 上限 200),
时间范围过滤是推荐用法而非可选项。
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, Query
from sqlalchemy import Date, cast, desc, func, select, text
from apps.admin.deps import SessionDep, admin_auth
from packages.shared.errors import AppError, ErrorCode
from packages.shared.models import ObEvent, User
from packages.shared.response import ok
router = APIRouter(tags=["admin:stats"], dependencies=[Depends(admin_auth)])
#: 原始事件流分页上限(契约固定值:ob_event 量大,不许一次拉爆)。
MAX_PAGE_SIZE = 200
#: 日桶时区:与 billing/频控的 UTC+8 自然窗口一致(FR-BILL-01 纪律)。
_DAY_BUCKET_OFFSET = text("INTERVAL '8 hours'")
#: 总览回溯窗口上限:90 天,防 ``days=100000`` 把整表做 group by。
MAX_OVERVIEW_DAYS = 90
def _day_bucket() -> Any:
"""server_ts 的 UTC+8 自然日桶(SQL 表达式,group by / select 两用)。"""
return cast(func.date_trunc("day", ObEvent.server_ts + _DAY_BUCKET_OFFSET), Date).label("day")
def _serialize_event(event: ObEvent) -> dict[str, Any]:
return {
"event_id": str(event.event_id),
"user_id": str(event.user_id),
"event_name": event.event_name,
"props": event.props,
"client_ts": event.client_ts.isoformat() if event.client_ts else None,
"server_ts": event.server_ts.isoformat() if event.server_ts else None,
}
def _parse_ts(raw: str, *, param: str) -> datetime:
"""时间参数解析:ISO 8601 或 epoch 秒(int/float)都接受。
无时区的 ISO 按 UTC 解释(与服务端落库口径一致)。
"""
try:
epoch = float(raw)
except ValueError:
try:
parsed = datetime.fromisoformat(raw)
except ValueError as exc:
raise AppError(
ErrorCode.BAD_REQUEST,
message=f"{param} 时间格式不合法",
hint="用 ISO 时间或 epoch 秒",
) from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed
return datetime.fromtimestamp(epoch, tz=UTC)
@router.get("/events", summary="原始事件流:user/event_name/时间范围过滤 + 分页")
async def list_events(
session: SessionDep,
user_id: int | None = Query(default=None),
event_name: str | None = Query(default=None, max_length=64),
start: str | None = Query(default=None, description="ISO 时间或 epoch 秒(含)"),
end: str | None = Query(default=None, description="ISO 时间或 epoch 秒(含)"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=MAX_PAGE_SIZE),
) -> dict[str, Any]:
conditions = []
if user_id is not None:
conditions.append(ObEvent.user_id == user_id)
if event_name:
conditions.append(ObEvent.event_name == event_name)
if start:
conditions.append(ObEvent.server_ts >= _parse_ts(start, param="start"))
if end:
conditions.append(ObEvent.server_ts <= _parse_ts(end, param="end"))
where = select(ObEvent).where(*conditions)
total = await session.scalar(select(func.count()).select_from(where.subquery()))
rows = (
(
await session.execute(
where.order_by(desc(ObEvent.server_ts))
.offset((page - 1) * page_size)
.limit(page_size)
)
)
.scalars()
.all()
)
return ok(
{
"items": [_serialize_event(row) for row in rows],
"total": total or 0,
"page": page,
"page_size": page_size,
}
)
@router.get("/stats/overview", summary="行为总览:按天 × event_name PV 透视 + 注册/活跃序列")
async def stats_overview(
session: SessionDep,
days: int = Query(default=7, ge=1, le=MAX_OVERVIEW_DAYS, description="回溯天数"),
) -> dict[str, Any]:
window_start = datetime.now(UTC) - timedelta(days=days)
day_bucket = _day_bucket()
pv_stmt = (
select(day_bucket, ObEvent.event_name, func.count().label("pv"))
.where(ObEvent.server_ts >= window_start)
.group_by("day", ObEvent.event_name)
.order_by("day", desc("pv"))
)
pv_rows = (await session.execute(pv_stmt)).all()
active_stmt = (
select(day_bucket, func.count(func.distinct(ObEvent.user_id)).label("active_users"))
.where(ObEvent.server_ts >= window_start)
.group_by("day")
.order_by("day")
)
active_rows = (await session.execute(active_stmt)).all()
registered_total = await session.scalar(select(func.count()).select_from(User))
return ok(
{
"days": days,
"window_start": window_start.isoformat(),
"registered_total": registered_total or 0,
"daily_active_users": [
{"day": row.day.isoformat(), "active_users": row.active_users}
for row in active_rows
],
"daily_event_pv": [
{"day": row.day.isoformat(), "event_name": row.event_name, "pv": row.pv}
for row in pv_rows
],
}
)