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

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