258 lines
9.0 KiB
Python
258 lines
9.0 KiB
Python
"""用户行为看板:用户列表 + 用户详情(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
|
||
],
|
||
}
|
||
)
|