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

This commit is contained in:
xsl
2026-09-15 00:02:55 +08:00
parent df460eeea8
commit 90349f735a
4 changed files with 798 additions and 2 deletions
+3 -2
View File
@@ -12,7 +12,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from apps.admin.routers import assets, gaps, kb, rules
from apps.admin.routers import assets, gaps, kb, rules, stats, users
from packages.shared.config import Settings, get_settings
from packages.shared.db import dispose_engine, get_session_factory
from packages.shared.handlers import install_exception_handlers
@@ -71,7 +71,8 @@ def create_admin_app(settings: Settings | None = None) -> FastAPI:
setup_tracing(app, cfg)
if deployed:
for router in (kb.router, rules.router, assets.router, gaps.router):
routers = (kb.router, rules.router, assets.router, gaps.router, users.router, stats.router)
for router in routers:
app.include_router(router, prefix=ADMIN_API_PREFIX)
else:
logger.warning(
+158
View File
@@ -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
],
}
)
+257
View File
@@ -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
],
}
)