159 lines
5.7 KiB
Python
159 lines
5.7 KiB
Python
"""行为统计:原始事件流 + 按天 × 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
|
||
],
|
||
}
|
||
)
|