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,380 @@
|
||||
"""admin 用户行为看板集成用例(真实 PostgreSQL,FR-ADM 行为分析能力)。
|
||||
|
||||
覆盖四个端点的字段与分页、X-Admin-Token 鉴权口径与现有 admin 一致
|
||||
(错误令牌 → 40100)、union_id 掩码(前 4 后 4,防 PII 直出)、配额快照
|
||||
只读不造行。种子数据用专属随机 user_id,断言全部相对种子而非全表。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
ADMIN_TOKEN = "it-admin-token-behavior"
|
||||
AUTH = {"X-Admin-Token": ADMIN_TOKEN}
|
||||
BAD_AUTH = {"X-Admin-Token": "wrong-token"}
|
||||
|
||||
_SEED_EVENT_NAMES = ("page_view", "outfit_card.tap")
|
||||
|
||||
|
||||
def _infra_available() -> bool:
|
||||
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
not _infra_available(),
|
||||
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _dispose_engine_after_test() -> AsyncIterator[None]:
|
||||
"""用例结束即释放全局引擎(理由同 test_admin_console.py)。"""
|
||||
yield
|
||||
from packages.shared.db import dispose_engine
|
||||
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> Any:
|
||||
import httpx
|
||||
|
||||
from apps.admin.app import create_admin_app
|
||||
from packages.shared.config import Settings
|
||||
|
||||
app = create_admin_app(Settings(admin_token=ADMIN_TOKEN))
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
|
||||
app.router.lifespan_context(app),
|
||||
):
|
||||
yield http
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seed() -> dict[str, Any]:
|
||||
"""造 2 个用户 + 身份 + 事件(7 天内/外各一)+ 报告 + 配额规则与用量。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from packages.domain.billing.quota import period_key
|
||||
from packages.domain.ids import next_id
|
||||
from packages.shared.db import get_session_factory
|
||||
from packages.shared.models import (
|
||||
ObEvent,
|
||||
Quota,
|
||||
QuotaRule,
|
||||
RpReport,
|
||||
User,
|
||||
UserIdentity,
|
||||
)
|
||||
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
user_a, user_b = next_id(), next_id()
|
||||
union_id_a = f"email:it-user-{suffix}@example.com"
|
||||
now = datetime.now(UTC)
|
||||
quota_key = f"it-quota-{suffix}"
|
||||
day_key = period_key(now, "day")
|
||||
|
||||
async with get_session_factory()() as session:
|
||||
session.add_all(
|
||||
[
|
||||
User(
|
||||
user_id=user_a,
|
||||
status="active",
|
||||
nickname=f"it-a-{suffix}",
|
||||
register_source="email",
|
||||
created_at=now - timedelta(days=3),
|
||||
),
|
||||
User(
|
||||
user_id=user_b,
|
||||
status="active",
|
||||
nickname=f"it-b-{suffix}",
|
||||
register_source="dev",
|
||||
created_at=now - timedelta(days=1),
|
||||
),
|
||||
UserIdentity(
|
||||
identity_id=next_id(),
|
||||
user_id=user_a,
|
||||
provider="email",
|
||||
union_id=union_id_a,
|
||||
),
|
||||
ObEvent(
|
||||
event_id=next_id(),
|
||||
user_id=user_a,
|
||||
event_name=_SEED_EVENT_NAMES[0],
|
||||
props={"page": "home"},
|
||||
server_ts=now - timedelta(hours=2),
|
||||
),
|
||||
ObEvent(
|
||||
event_id=next_id(),
|
||||
user_id=user_a,
|
||||
event_name=_SEED_EVENT_NAMES[1],
|
||||
props={"card_id": 1},
|
||||
server_ts=now - timedelta(days=1),
|
||||
),
|
||||
# 8 天前:在 7 天窗口外——event_count_7d 不该算它
|
||||
ObEvent(
|
||||
event_id=next_id(),
|
||||
user_id=user_a,
|
||||
event_name=_SEED_EVENT_NAMES[0],
|
||||
props={"page": "old"},
|
||||
server_ts=now - timedelta(days=8),
|
||||
),
|
||||
ObEvent(
|
||||
event_id=next_id(),
|
||||
user_id=user_b,
|
||||
event_name=_SEED_EVENT_NAMES[0],
|
||||
props={"page": "home"},
|
||||
server_ts=now - timedelta(hours=1),
|
||||
),
|
||||
RpReport(
|
||||
report_id=next_id(),
|
||||
user_id=user_a,
|
||||
tier="basic",
|
||||
status="ready",
|
||||
version=1,
|
||||
),
|
||||
QuotaRule(
|
||||
quota_key=quota_key,
|
||||
period_type="day",
|
||||
base_limit=10,
|
||||
task_bonus_max=0,
|
||||
hard_cap=20,
|
||||
),
|
||||
Quota(
|
||||
user_id=user_a,
|
||||
quota_key=quota_key,
|
||||
period_key=day_key,
|
||||
used=3,
|
||||
limit_value=10,
|
||||
bonus_granted=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
rules_before = len(
|
||||
(await session.execute(select(QuotaRule).where(QuotaRule.quota_key == quota_key)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"user_a": user_a,
|
||||
"user_b": user_b,
|
||||
"quota_key": quota_key,
|
||||
"day_key": day_key,
|
||||
"rules_before": rules_before,
|
||||
"union_id_a": union_id_a,
|
||||
}
|
||||
|
||||
|
||||
async def test_wrong_token_rejected_like_existing_admin(client: Any) -> None:
|
||||
"""鉴权口径与现有 admin 一致:错误令牌 → 40100。"""
|
||||
resp = await client.get("/admin/api/v1/users", headers=BAD_AUTH)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 40100
|
||||
|
||||
resp = await client.get("/admin/api/v1/events", headers=BAD_AUTH)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 40100
|
||||
|
||||
resp = await client.get("/admin/api/v1/stats/overview", headers=BAD_AUTH)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 40100
|
||||
|
||||
# 无令牌头:同样 40100(compare_digest 对空串,不炸 500)
|
||||
resp = await client.get("/admin/api/v1/users")
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 40100
|
||||
|
||||
|
||||
async def test_users_list_fields_and_filters(client: Any, seed: dict[str, Any]) -> None:
|
||||
resp = await client.get("/admin/api/v1/users", headers=AUTH, params={"query": "email"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()["data"]
|
||||
assert body["page"] == 1
|
||||
assert body["page_size"] == 20
|
||||
assert body["total"] >= 1
|
||||
assert all(item["register_source"] == "email" for item in body["items"])
|
||||
|
||||
# query 按 user_id 精确:只命中种子用户 A——字段契约全在它身上断言,
|
||||
# 不受库里其它用户数据/分页位置影响
|
||||
resp = await client.get(
|
||||
"/admin/api/v1/users", headers=AUTH, params={"query": str(seed["user_a"])}
|
||||
)
|
||||
body = resp.json()["data"]
|
||||
assert [item["user_id"] for item in body["items"]] == [str(seed["user_a"])]
|
||||
row_a = body["items"][0]
|
||||
assert row_a["register_source"] == "email"
|
||||
assert row_a["created_at"] is not None
|
||||
assert row_a["last_active_at"] is not None
|
||||
assert row_a["event_count_7d"] == 2 # 8 天外那条不算
|
||||
assert row_a["report_count"] == 1
|
||||
|
||||
|
||||
async def test_users_list_pagination(client: Any, seed: dict[str, Any]) -> None:
|
||||
resp = await client.get(
|
||||
"/admin/api/v1/users",
|
||||
headers=AUTH,
|
||||
params={"query": "dev", "page": 1, "page_size": 1},
|
||||
)
|
||||
body = resp.json()["data"]
|
||||
assert body["page_size"] == 1
|
||||
assert len(body["items"]) == 1
|
||||
assert body["total"] >= 1
|
||||
|
||||
|
||||
async def test_user_detail_masks_identity_and_snapshots_quota(
|
||||
client: Any, seed: dict[str, Any]
|
||||
) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from packages.shared.db import get_session_factory
|
||||
from packages.shared.models import Quota, UserIdentity
|
||||
|
||||
user_id = seed["user_a"]
|
||||
resp = await client.get(f"/admin/api/v1/users/{user_id}", headers=AUTH)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
|
||||
# 基本信息
|
||||
assert data["user"]["user_id"] == str(user_id)
|
||||
assert data["user"]["register_source"] == "email"
|
||||
|
||||
# 身份:provider 明文、union_id 只留前 4 后 4(PII 直出防护,逐字符断言)
|
||||
assert len(data["identities"]) == 1
|
||||
ident = data["identities"][0]
|
||||
assert ident["provider"] == "email"
|
||||
raw = seed["union_id_a"]
|
||||
expected = f"{raw[:4]}{'*' * (len(raw) - 8)}{raw[-4:]}"
|
||||
assert ident["union_id"] == expected
|
||||
assert raw not in str(data["identities"])
|
||||
|
||||
# 配额快照:种子用量 used=3,limit=base 10,remaining=7
|
||||
quota_rows = [q for q in data["quotas"] if q["quota_key"] == seed["quota_key"]]
|
||||
assert len(quota_rows) == 1
|
||||
assert quota_rows[0]["used"] == 3
|
||||
assert quota_rows[0]["limit"] == 10
|
||||
assert quota_rows[0]["remaining"] == 7
|
||||
assert quota_rows[0]["period_key"] == seed["day_key"]
|
||||
|
||||
# 只读断言:看板不产生写副作用(身份与计量行数不变)
|
||||
async with get_session_factory()() as session:
|
||||
idents = (
|
||||
(await session.execute(select(UserIdentity).where(UserIdentity.user_id == user_id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
quotas = (
|
||||
(
|
||||
await session.execute(
|
||||
select(Quota).where(
|
||||
Quota.user_id == user_id, Quota.quota_key == seed["quota_key"]
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(idents) == 1
|
||||
assert len(quotas) == 1
|
||||
assert quotas[0].used == 3
|
||||
|
||||
# 最近事件/报告:各落一条种子数据
|
||||
assert len(data["recent_events"]) == 3 # 2 条 7 天内 + 1 条 8 天前(窗口不按详情截)
|
||||
assert data["recent_events"][0]["server_ts"] >= data["recent_events"][-1]["server_ts"]
|
||||
assert len(data["recent_reports"]) == 1
|
||||
report = data["recent_reports"][0]
|
||||
assert report["tier"] == "basic"
|
||||
assert report["status"] == "ready"
|
||||
assert report["version"] == 1
|
||||
assert report["report_id"]
|
||||
|
||||
|
||||
async def test_user_detail_not_found(client: Any) -> None:
|
||||
resp = await client.get("/admin/api/v1/users/0", headers=AUTH)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["code"] == 40400
|
||||
|
||||
|
||||
async def test_events_stream_filters_and_pagination(client: Any, seed: dict[str, Any]) -> None:
|
||||
user_id = seed["user_a"]
|
||||
# user_id 过滤:只出种子用户的事件,server_ts 倒序
|
||||
resp = await client.get(
|
||||
"/admin/api/v1/events",
|
||||
headers=AUTH,
|
||||
params={"user_id": user_id, "page_size": 2, "page": 1},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()["data"]
|
||||
assert body["total"] == 3
|
||||
assert len(body["items"]) == 2
|
||||
assert all(item["user_id"] == str(user_id) for item in body["items"])
|
||||
first, second = body["items"][0]["server_ts"], body["items"][1]["server_ts"]
|
||||
assert first >= second
|
||||
|
||||
# 翻第二页拿到最早那条(8 天前),证明分页生效
|
||||
resp = await client.get(
|
||||
"/admin/api/v1/events",
|
||||
headers=AUTH,
|
||||
params={"user_id": user_id, "page_size": 2, "page": 2},
|
||||
)
|
||||
page2 = resp.json()["data"]
|
||||
assert len(page2["items"]) == 1
|
||||
|
||||
# event_name 过滤 + ISO 时间范围:8 天前那条被 start 挡在窗外
|
||||
start = (datetime.now(UTC) - timedelta(days=7)).isoformat()
|
||||
resp = await client.get(
|
||||
"/admin/api/v1/events",
|
||||
headers=AUTH,
|
||||
params={"user_id": user_id, "event_name": _SEED_EVENT_NAMES[0], "start": start},
|
||||
)
|
||||
body = resp.json()["data"]
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["props"] == {"page": "home"}
|
||||
|
||||
# epoch 秒也接受(与 ISO 等价窗口)
|
||||
epoch_start = str(int((datetime.now(UTC) - timedelta(days=7)).timestamp()))
|
||||
resp = await client.get(
|
||||
"/admin/api/v1/events",
|
||||
headers=AUTH,
|
||||
params={"user_id": user_id, "event_name": _SEED_EVENT_NAMES[0], "start": epoch_start},
|
||||
)
|
||||
assert resp.json()["data"]["total"] == 1
|
||||
|
||||
# page_size 超上限 → 422(契约上限 200)
|
||||
resp = await client.get("/admin/api/v1/events", headers=AUTH, params={"page_size": 201})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_stats_overview_pivot_and_series(client: Any, seed: dict[str, Any]) -> None:
|
||||
resp = await client.get("/admin/api/v1/stats/overview", headers=AUTH, params={"days": 7})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["days"] == 7
|
||||
assert data["registered_total"] >= 2
|
||||
|
||||
# 透视表:种子用户的两个事件名都出现,PV 至少为种子条数
|
||||
pv = {(row["event_name"]) for row in data["daily_event_pv"]}
|
||||
assert _SEED_EVENT_NAMES[0] in pv
|
||||
assert _SEED_EVENT_NAMES[1] in pv
|
||||
page_view_pv = sum(
|
||||
row["pv"] for row in data["daily_event_pv"] if row["event_name"] == _SEED_EVENT_NAMES[0]
|
||||
)
|
||||
assert page_view_pv >= 2 # 种子 A、B 各至少一条(库内其它数据只会更多)
|
||||
|
||||
# 活跃序列:至少一天有活跃用户
|
||||
assert data["daily_active_users"]
|
||||
assert max(row["active_users"] for row in data["daily_active_users"]) >= 1
|
||||
|
||||
# days 超上限 → 422
|
||||
resp = await client.get("/admin/api/v1/stats/overview", headers=AUTH, params={"days": 91})
|
||||
assert resp.status_code == 422
|
||||
Reference in New Issue
Block a user