"""统计 API(Phase 2 Task 9): 卡片/漏斗/趋势/分布/推送健康。 环境与种子纪律同 test_data_views: db_source_app/super_client(DB 源闭环, 租户全部经创建 API 进入 registry),租户库五表独立 NullPool engine 直连 插数(一次 asyncio.run 内建表+插数+dispose,绝不碰 registry 的 pool=2 常驻连接——跨循环纪律)。 口径钉子(与 app/platform/stats.py docstring 同源,Phase 3 前端照此展示): - 线索 = customer_profiles.raw_info 含 phone/whatsapp/email(三选一)任一 非空——wechat 不算(推送侧只认三选一);会话数不要求有消息(统计口径 =发生的会话,与数据视图列表"至少一条消息"的 jc 口径不同)。 - 趋势按北京日界分组(date(created_at, '+8 hours'))——UTC 20:00 落北京 次日;from/to 筛选是 naive UTC 边界(与 data_routes 同解析),与北京 日界是两套口径,测试各自钉住。 - 推送指标(成功率/推送健康)按 lead_push_logs.created_at 滤窗; 漏斗的"推送成功"级 = 会话有过 success 日志(会话本体已在窗内)。 - 合并视图含停用租户数据(Task 8 偏差 4 语义: 后台可见停用租户数据), 跳过库文件不存在的未懒建租户(零数据等价,不为统计建空库)。 """ import asyncio from datetime import datetime, timezone from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from app.engine.models import ( Base, Conversation, ConversationSource, CustomerProfile, Evaluation, IntentLevel, LeadPushLog, Message, MessageRole, ) TENANTS = "/api/platform/tenants" STATS = "/api/platform/stats" LOGIN = "/api/platform/auth/login" CHANGE_PW = "/api/platform/auth/change-password" UTC = timezone.utc # ---- 助手(同 test_data_views 的模式) -------------------------------------------- def _auth(client): return {"Authorization": f"Bearer {client.platform_token}"} def _bearer(token): return {"Authorization": f"Bearer {token}"} def _create(client, slug, admin=None, password=None, profile="jiancai"): return client.post(TENANTS, headers=_auth(client), json={ "name": f"{slug} 租户", "slug": slug, "profile": profile, "admin_username": admin or f"{slug}-admin", "admin_password": password or f"{slug}-pass-1", "domains": [], }) def _tenant_admin_token(client, username, old_password, new_password="tn-pass-1"): r = client.post(LOGIN, json={"username": username, "password": old_password}) assert r.status_code == 200 token = r.json()["token"] r = client.post(CHANGE_PW, headers=_bearer(token), json={ "old_password": old_password, "new_password": new_password}) assert r.status_code == 200 return token def _tenant_engine(root, slug): tenants = root / "tenants" tenants.mkdir(parents=True, exist_ok=True) return create_async_engine( f"sqlite+aiosqlite:///{(tenants / f'{slug}.db').as_posix()}", poolclass=NullPool) def _seed(root, slug, populate): """直连租户库造数: create_all + populate(session) + commit,跑完 dispose。""" async def run(): engine = _tenant_engine(root, slug) try: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with async_sessionmaker(engine, expire_on_commit=False)() as session: await populate(session) await session.commit() finally: await engine.dispose() asyncio.run(run()) # ---- 种子工厂 --------------------------------------------------------------------- def _conv(cid, at, *, site="main", ip="127.0.0.1", source=ConversationSource.widget): client_info = {"ip": ip, "user_agent": f"UA-{cid}"} if site: client_info["site"] = site return Conversation( id=cid, session_id=f"sess-{cid}", source=source, client_info=client_info, created_at=at, updated_at=at) def _msg(cid, content, at): return Message(conversation_id=cid, role=MessageRole.user, content=content, created_at=at) def _profile(cid, raw): return CustomerProfile(conversation_id=cid, raw_info=raw) def _eval(cid, intent, at): return Evaluation(conversation_id=cid, intent_level=intent, evaluated_at=at) def _push(ev_id, cid, status, at, *, outcome=None, push_type="lead", attempts=1, error=None): return LeadPushLog( evaluation_id=ev_id, conversation_id=cid, push_type=push_type, status=status, attempts=attempts, outcome=outcome, error_message=error, created_at=at, updated_at=at) def _stats(client, path, token=None, query=""): r = client.get(f"{path}{query}", headers=_auth(client) if token is None else _bearer(token)) assert r.status_code == 200, r.text return r.json() # ---- 卡片 / 漏斗 ------------------------------------------------------------------ def test_cards_and_funnel(super_client, tmp_path): """会话5 → 留资3 → 评估完成2 → 推送成功1(精确数断言)。""" _create(super_client, slug="demo") async def populate(session): at = datetime(2026, 1, 5, 10, 0, tzinfo=UTC) # c1: 留资(phone)+评估 interested+推送成功 session.add(_conv("c1", at)) session.add(_msg("c1", "您好,我想买切割机", at)) session.add(_profile("c1", {"phone": "+8613800000000"})) ev1 = _eval("c1", IntentLevel.interested, at) session.add(ev1) await session.flush() session.add(_push(ev1.id, "c1", "success", at, outcome="accepted")) # c2: 留资(whatsapp)+评估 pending+推送失败 session.add(_conv("c2", at, site="es")) session.add(_msg("c2", "just looking", at)) session.add(_profile("c2", {"whatsapp": "+2348000000000"})) ev2 = _eval("c2", IntentLevel.pending, at) session.add(ev2) await session.flush() session.add(_push(ev2.id, "c2", "failed", at, error="upstream 500")) # c3: 留资(email),无评估 session.add(_conv("c3", at)) session.add(_msg("c3", "need price", at)) session.add(_profile("c3", {"email": "buy@example.com"})) # c4: 画像只有 wechat 与空 phone——不算线索(三选一口径钉子) session.add(_conv("c4", at, site="es")) session.add(_msg("c4", "hi", at)) session.add(_profile("c4", {"wechat": "wx-123", "phone": ""})) # c5: 只有会话(零消息也计入——统计口径=发生的会话) session.add(_conv("c5", at)) _seed(tmp_path, "demo", populate) body = _stats(super_client, f"{TENANTS}/demo/stats") assert body["cards"] == { "conversations": 5, "leads": 3, "high_intent": 1, "push_success_rate": 0.5} # 1/(1+1) assert body["funnel"] == { "conversations": 5, "leads": 3, "evaluated": 2, "push_succeeded": 1} assert body["push_health"] == { "pending": 0, "success": 1, "failed": 1, "skipped": 0, "duplicated": 0} # ---- 趋势 ------------------------------------------------------------------------- def test_daily_trend(super_client, tmp_path): """两天的会话数按日分组——北京日界(UTC 20:00 落北京次日 1/6)。""" _create(super_client, slug="demo") async def populate(session): a1 = datetime(2026, 1, 5, 10, 0, tzinfo=UTC) # 北京 1/5 18:00 a2 = datetime(2026, 1, 5, 20, 0, tzinfo=UTC) # 北京 1/6 04:00 a3 = datetime(2026, 1, 6, 15, 0, tzinfo=UTC) # 北京 1/6 23:00 session.add(_conv("t1", a1)) session.add(_msg("t1", "one", a1)) session.add(_conv("t2", a2)) session.add(_msg("t2", "two", a2)) session.add(_profile("t2", {"phone": "+8613800000001"})) session.add(_conv("t3", a3)) session.add(_msg("t3", "three", a3)) session.add(_profile("t3", {"email": "t3@example.com"})) _seed(tmp_path, "demo", populate) body = _stats(super_client, f"{TENANTS}/demo/stats") # 北京日界: 若按 UTC 分组会是 1/5=2、1/6=1——下面断言钉住 +8 小时口径 assert body["trend"] == [ {"date": "2026-01-05", "conversations": 1, "leads": 0}, {"date": "2026-01-06", "conversations": 2, "leads": 2}, ] # from/to 是 naive UTC 边界(data_routes 同款解析): from=2026-01-06 # 排除 t1/t2(1/5 UTC),只剩 t3 body = _stats(super_client, f"{TENANTS}/demo/stats", query="?from=2026-01-06") assert body["trend"] == [ {"date": "2026-01-06", "conversations": 1, "leads": 1}] assert body["cards"]["conversations"] == 1 # to=日期-only 含当日全天(至 23:59:59.999999 UTC)→ t1+t2 body = _stats(super_client, f"{TENANTS}/demo/stats", query="?to=2026-01-05") assert body["trend"] == [ {"date": "2026-01-05", "conversations": 1, "leads": 0}, {"date": "2026-01-06", "conversations": 1, "leads": 1}, ] # 坏日期显性 400 assert super_client.get( f"{TENANTS}/demo/stats?from=not-a-date", headers=_auth(super_client)).status_code == 400 # ---- 分布 ------------------------------------------------------------------------- def test_distributions(super_client, tmp_path): """intent 三级占比 / source / site / country top(IP 归国)。""" _create(super_client, slug="demo") async def populate(session): at = datetime(2026, 1, 5, 10, 0, tzinfo=UTC) # intent source site ip session.add(_conv("c1", at, site="main", ip="8.8.8.8")) session.add(_eval("c1", IntentLevel.interested, at)) session.add(_conv("c2", at, site="es", ip="8.8.8.8")) session.add(_eval("c2", IntentLevel.interested, at)) session.add(_conv("c3", at, site="main", ip="127.0.0.1")) session.add(_eval("c3", IntentLevel.not_interested, at)) session.add(_conv("c4", at, site="es", ip="", source=ConversationSource.standalone)) session.add(_eval("c4", IntentLevel.pending, at)) # c5: 无评估 / app 来源 / 无 site / 本地 IP session.add(_conv("c5", at, site=None, source=ConversationSource.app)) _seed(tmp_path, "demo", populate) body = _stats(super_client, f"{TENANTS}/demo/stats") dist = body["distributions"] # intent 三级(无评估的 c5 不在任何桶) assert dist["intent"] == { "interested": 2, "not_interested": 1, "pending": 1} # source(计数倒序、同数按 key 序——确定性排序钉子) assert list(dist["source"].items()) == [ ("widget", 3), ("app", 1), ("standalone", 1)] # site(client_info JSON 取键;无 site 的 c5 不计) assert dist["site"] == {"main": 2, "es": 2} # country top(IP 归国;8.8.8.8→美国,127.0.0.1/空→本地) assert dist["country"] == [ {"country": "本地", "count": 3}, {"country": "美国", "count": 2}] # ---- 推送健康 --------------------------------------------------------------------- def test_push_health(super_client, tmp_path): """四状态计数 + duplicated 计数;推送指标按日志 created_at 滤窗。""" _create(super_client, slug="demo") async def populate(session): old = datetime(2025, 12, 30, 10, 0, tzinfo=UTC) # 窗外(2025) at = datetime(2026, 1, 5, 10, 0, tzinfo=UTC) # 窗外的老会话+老失败日志: 默认无筛选时计入,?from=2026-01-01 起排除 session.add(_conv("c0", old)) ev0 = _eval("c0", IntentLevel.interested, old) session.add(ev0) await session.flush() session.add(_push(ev0.id, "c0", "failed", old)) session.add(_conv("c1", at)) ev1 = _eval("c1", IntentLevel.interested, at) session.add(ev1) session.add(_conv("c2", at)) ev2 = _eval("c2", IntentLevel.pending, at) session.add(ev2) await session.flush() session.add(_push(ev1.id, "c1", "success", at, outcome="accepted")) # duplicated 不是错误: 200 但对方判重复 session.add(_push(ev1.id, "c1", "success", at, outcome="duplicated", push_type="correction")) session.add(_push(ev2.id, "c2", "failed", at, error="upstream 500")) session.add(_push(ev2.id, "c2", "skipped", at)) session.add(_push(ev2.id, "c2", "pending", at, attempts=0)) _seed(tmp_path, "demo", populate) body = _stats(super_client, f"{TENANTS}/demo/stats") assert body["push_health"] == { "pending": 1, "success": 2, "failed": 2, "skipped": 1, "duplicated": 1} # 成功率 = success/(success+failed) = 2/4 assert body["cards"]["push_success_rate"] == 0.5 # 窗内: 老日志出局,会话与评估同窗排除 body = _stats(super_client, f"{TENANTS}/demo/stats", query="?from=2026-01-01") assert body["push_health"] == { "pending": 1, "success": 2, "failed": 1, "skipped": 1, "duplicated": 1} assert abs(body["cards"]["push_success_rate"] - 2 / 3) < 1e-9 assert body["cards"]["conversations"] == 2 # ---- 跨租户合并 / 鉴权边界 --------------------------------------------------------- def test_cross_tenant_super_view(super_client, tmp_path): """超管无 tenant 参数=全部租户合并(逐库查询应用层合并);有=单租户。 合并含停用租户、跳过无库文件的未懒建租户;403/401 边界。""" _create(super_client, slug="jca", admin="jca-admin", password="jca-pass-1") # blank: 第二/三个租户不撞 jiancai 出厂 push.prefix(终审 I-3) _create(super_client, slug="jcb", admin="jcb-admin", password="jcb-pass-1", profile="blank") # jcc: 创建后从未 resolve——库文件不存在,合并视图应跳过(不建空库) _create(super_client, slug="jcc", profile="blank") async def seed_a(session): a1 = datetime(2026, 1, 5, 10, 0, tzinfo=UTC) # 北京 1/5 a2 = datetime(2026, 1, 5, 20, 0, tzinfo=UTC) # 北京 1/6 session.add(_conv("a1", a1)) session.add(_profile("a1", {"phone": "+8613800000000"})) ev = _eval("a1", IntentLevel.interested, a1) session.add(ev) session.add(_conv("a2", a2)) await session.flush() session.add(_push(ev.id, "a1", "success", a1, outcome="accepted")) async def seed_b(session): b1 = datetime(2026, 1, 6, 15, 0, tzinfo=UTC) # 北京 1/6 session.add(_conv("b1", b1)) session.add(_profile("b1", {"email": "b1@example.com"})) session.add(_eval("b1", IntentLevel.not_interested, b1)) _seed(tmp_path, "jca", seed_a) _seed(tmp_path, "jcb", seed_b) # 合并视图 = jca + jcb(jcc 无库文件被跳过;若有第三库数字会多) body = _stats(super_client, STATS) assert body["cards"] == { "conversations": 3, "leads": 2, "high_intent": 1, "push_success_rate": 1.0} # 1/(1+0) assert body["funnel"] == { "conversations": 3, "leads": 2, "evaluated": 2, "push_succeeded": 1} assert body["trend"] == [ {"date": "2026-01-05", "conversations": 1, "leads": 1}, {"date": "2026-01-06", "conversations": 2, "leads": 1}, ] # 回归钉子: merge_stats 的 distributions 必须是对象(曾因赋值行尾随逗号 # 变 1 元组序列化成 JSON 数组,单租户路径不受累——Phase 3 Task 8 冒烟发现) assert body["distributions"]["intent"] == { "interested": 1, "not_interested": 1, "pending": 0} assert body["push_health"]["success"] == 1 assert not (tmp_path / "tenants" / "jcc.db").exists() # 未为统计建空库 # 单租户下钻 body = _stats(super_client, STATS, query="?tenant=jca") assert body["cards"]["conversations"] == 2 assert body["cards"]["leads"] == 1 body = _stats(super_client, STATS, query="?tenant=jcb") assert body["cards"]["conversations"] == 1 assert body["cards"]["push_success_rate"] is None # 无终态日志 → null # 未知 tenant slug → 404 assert super_client.get( f"{STATS}?tenant=ghost", headers=_auth(super_client)).status_code == 404 # 停用 jcb 后合并视图仍含其数据(Task 8 偏差 4 语义: 后台可见停用租户) r = super_client.patch(f"{TENANTS}/jcb", headers=_auth(super_client), json={"status": "disabled"}) assert r.status_code == 200 body = _stats(super_client, STATS) assert body["cards"]["conversations"] == 3 # 租户管理员: 自己租户 200;他租户 403;超管合并端点 403;未认证 401 token = _tenant_admin_token(super_client, "jca-admin", "jca-pass-1") body = _stats(super_client, f"{TENANTS}/jca/stats", token=token) assert body["cards"]["conversations"] == 2 assert super_client.get( f"{TENANTS}/jcb/stats", headers=_bearer(token)).status_code == 403 assert super_client.get( STATS, headers=_bearer(token)).status_code == 403 assert super_client.get(STATS).status_code == 401 assert super_client.get(f"{TENANTS}/jca/stats").status_code == 401