feat(admin): 用户行为分析看板——/admin/api/v1/users(列表/详情,identity 掩码后输出)、/events(多维过滤分页)、/stats/overview(按天×事件 PV 透视+活跃序列);X-Admin-Token 鉴权沿用现有 admin 装配
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"""行为统计:原始事件流 + 按天 × 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
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""用户行为看板:用户列表 + 用户详情(FR-ADM 行为分析能力)。
|
||||
|
||||
与 kb.py 的关键差异:这里直接读 ``packages.shared.models`` 的观测/计费/
|
||||
报告表做只读透视,不经任何域 service——后台看板要的是「跨域拼宽表」,
|
||||
而域 service 的接口是为客户端单用户视角设计的,复用反而拧。
|
||||
|
||||
PII 纪律:``union_id`` 只留前 4 后 4、中间打码后出(防 PII 直出后台),
|
||||
掩码逻辑写在响应组装处(本模块),不许下沉到查询层。
|
||||
|
||||
鉴权走 ``X-Admin-Token``(共享令牌,见 apps.admin.deps)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import Select, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.admin.deps import SessionDep, admin_auth
|
||||
from packages.domain.billing.quota import QuotaState, period_key
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.models import (
|
||||
ObEvent,
|
||||
Quota,
|
||||
QuotaRule,
|
||||
RpReport,
|
||||
User,
|
||||
UserIdentity,
|
||||
)
|
||||
from packages.shared.response import ok
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["admin:users"], dependencies=[Depends(admin_auth)])
|
||||
|
||||
#: 列表/详情分页与「最近事件」窗口的共用上限(ob_event 量大,不许一次拉爆)。
|
||||
MAX_PAGE_SIZE = 200
|
||||
RECENT_EVENTS_LIMIT = 50
|
||||
RECENT_REPORTS_LIMIT = 10
|
||||
_EVENT_COUNT_WINDOW = timedelta(days=7)
|
||||
|
||||
|
||||
def _mask_union_id(union_id: str) -> str:
|
||||
"""PII 打码:只留前 4 后 4,中间一律 ``*``;不足 9 位的整体打码。"""
|
||||
if len(union_id) <= 8:
|
||||
return union_id[:2] + "***"
|
||||
return f"{union_id[:4]}{'*' * (len(union_id) - 8)}{union_id[-4:]}"
|
||||
|
||||
|
||||
def _user_list_stmt(query: str | None) -> Select[Any]:
|
||||
"""用户列表主查询:三个标量关联子查询拼出 last_active/event_count/report_count。
|
||||
|
||||
都是 ``(user_id)`` 上的索引查找,不走 join 放大行数——ob_event/rp_report
|
||||
与 id_user 一对多,join 后 count 需要 distinct, correlated subquery 更直白。
|
||||
"""
|
||||
window_start = datetime.now(UTC) - _EVENT_COUNT_WINDOW
|
||||
last_active = (
|
||||
select(func.max(ObEvent.server_ts)).where(ObEvent.user_id == User.user_id).scalar_subquery()
|
||||
)
|
||||
event_count_7d = (
|
||||
select(func.count())
|
||||
.where(ObEvent.user_id == User.user_id, ObEvent.server_ts >= window_start)
|
||||
.scalar_subquery()
|
||||
)
|
||||
report_count = (
|
||||
select(func.count())
|
||||
.select_from(RpReport)
|
||||
.where(RpReport.user_id == User.user_id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
stmt = select(
|
||||
User.user_id,
|
||||
User.register_source,
|
||||
User.created_at,
|
||||
last_active.label("last_active_at"),
|
||||
event_count_7d.label("event_count_7d"),
|
||||
report_count.label("report_count"),
|
||||
)
|
||||
if query:
|
||||
# query 兼容两种过滤:能解析成 int → user_id 精确;否则按注册来源等值
|
||||
if query.isdigit():
|
||||
stmt = stmt.where(User.user_id == int(query))
|
||||
else:
|
||||
stmt = stmt.where(User.register_source == query)
|
||||
return stmt
|
||||
|
||||
|
||||
def _serialize_user_row(row: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"user_id": str(row.user_id),
|
||||
"register_source": row.register_source,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"last_active_at": row.last_active_at.isoformat() if row.last_active_at else None,
|
||||
"event_count_7d": row.event_count_7d,
|
||||
"report_count": row.report_count,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", summary="用户列表:query 按 user_id 精确 / register_source 过滤 + 分页")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
query: str | None = Query(default=None, max_length=64, description="user_id 或注册来源"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=MAX_PAGE_SIZE),
|
||||
) -> dict[str, Any]:
|
||||
stmt = _user_list_stmt(query)
|
||||
total = await session.scalar(select(func.count()).select_from(stmt.subquery()))
|
||||
rows = (
|
||||
await session.execute(
|
||||
stmt.order_by(desc(User.created_at)).offset((page - 1) * page_size).limit(page_size)
|
||||
)
|
||||
).all()
|
||||
return ok(
|
||||
{
|
||||
"items": [_serialize_user_row(row) for row in rows],
|
||||
"total": total or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _quota_snapshot(session: AsyncSession, user_id: int) -> list[dict[str, Any]]:
|
||||
"""各 quota_key 的本周期用量快照(used/limit/remaining)。
|
||||
|
||||
只读:不 ``lock_or_create_quota``(那会为用户造行,看板不该有写副作用)。
|
||||
规则表全量读出(V1 规则个位数),计量行按本周期 period_key 一次性查回。
|
||||
"""
|
||||
rules = (await session.execute(select(QuotaRule).order_by(QuotaRule.quota_key))).scalars().all()
|
||||
now = datetime.now(UTC)
|
||||
keys = {
|
||||
rule.quota_key: period_key(now, rule.period_type) # type: ignore[arg-type]
|
||||
for rule in rules
|
||||
}
|
||||
quota_rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(Quota).where(
|
||||
Quota.user_id == user_id,
|
||||
Quota.quota_key.in_(keys.keys()),
|
||||
Quota.period_key.in_(set(keys.values())),
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
by_key = {(row.quota_key, row.period_key): row for row in quota_rows}
|
||||
snapshot = []
|
||||
for rule in rules:
|
||||
row = by_key.get((rule.quota_key, keys[rule.quota_key]))
|
||||
used = row.used if row is not None else 0
|
||||
bonus = row.bonus_granted if row is not None else 0
|
||||
state = QuotaState(
|
||||
used=used, base_limit=rule.base_limit, bonus=bonus, hard_cap=rule.hard_cap
|
||||
)
|
||||
snapshot.append(
|
||||
{
|
||||
"quota_key": rule.quota_key,
|
||||
"period_key": keys[rule.quota_key],
|
||||
"used": used,
|
||||
"limit": state.effective_limit,
|
||||
"remaining": state.remaining,
|
||||
}
|
||||
)
|
||||
return snapshot
|
||||
|
||||
|
||||
def _serialize_event(event: ObEvent) -> dict[str, Any]:
|
||||
return {
|
||||
"event_id": str(event.event_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,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{user_id}", summary="用户详情:identities(掩码)+ 配额快照 + 最近事件/报告")
|
||||
async def get_user_detail(user_id: int, session: SessionDep) -> dict[str, Any]:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, message="用户不存在")
|
||||
|
||||
identities = (
|
||||
(
|
||||
await session.execute(
|
||||
select(UserIdentity)
|
||||
.where(UserIdentity.user_id == user_id)
|
||||
.order_by(UserIdentity.created_at)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
events = (
|
||||
(
|
||||
await session.execute(
|
||||
select(ObEvent)
|
||||
.where(ObEvent.user_id == user_id)
|
||||
.order_by(desc(ObEvent.server_ts))
|
||||
.limit(RECENT_EVENTS_LIMIT)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
reports = (
|
||||
(
|
||||
await session.execute(
|
||||
select(RpReport)
|
||||
.where(RpReport.user_id == user_id)
|
||||
.order_by(desc(RpReport.created_at))
|
||||
.limit(RECENT_REPORTS_LIMIT)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
quotas = await _quota_snapshot(session, user_id)
|
||||
|
||||
return ok(
|
||||
{
|
||||
"user": {
|
||||
"user_id": str(user.user_id),
|
||||
"status": user.status,
|
||||
"nickname": user.nickname,
|
||||
"register_source": user.register_source,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"last_active_at": (
|
||||
user.last_active_at.isoformat() if user.last_active_at else None
|
||||
),
|
||||
"deleted_at": user.deleted_at.isoformat() if user.deleted_at else None,
|
||||
},
|
||||
"identities": [
|
||||
{
|
||||
"provider": ident.provider,
|
||||
"union_id": _mask_union_id(ident.union_id),
|
||||
"created_at": ident.created_at.isoformat() if ident.created_at else None,
|
||||
}
|
||||
for ident in identities
|
||||
],
|
||||
"quotas": quotas,
|
||||
"recent_events": [_serialize_event(event) for event in events],
|
||||
"recent_reports": [
|
||||
{
|
||||
"report_id": str(report.report_id),
|
||||
"tier": report.tier,
|
||||
"version": report.version,
|
||||
"status": report.status,
|
||||
"created_at": report.created_at.isoformat() if report.created_at else None,
|
||||
}
|
||||
for report in reports
|
||||
],
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user