diff --git a/app/main.py b/app/main.py index 65a449f..b80caa3 100644 --- a/app/main.py +++ b/app/main.py @@ -86,6 +86,7 @@ from app.platform.routes.config_routes import ( settings_router as platform_settings_router, ) from app.platform.routes.data_routes import router as platform_data_router +from app.platform.routes.stats_routes import router as platform_stats_router from app.platform.routes.tenant_routes import router as platform_tenant_router from app.platform.routes.upload_routes import router as platform_upload_router app.include_router(chat_router) @@ -106,6 +107,10 @@ app.include_router(platform_upload_router) # runtime 的 session_factory 上(slug → runtimes() 匹配 + ensure_ready 懒建), # 鉴权 _tenant_guard;查询语义自 jc admin.py 移植。 app.include_router(platform_data_router) +# 统计 API(Task 9): 卡片/漏斗/趋势/分布/推送健康——聚合在 app/platform/ +# stats.py,逐库查询应用层合并(禁 ATTACH,spec §7.6);合并端点 require_super +# (?tenant= 单租户下钻),单租户端点 _tenant_guard。 +app.include_router(platform_stats_router) # 平台认证路由(Task 2):本任务即挂——已知租户 Host(如 jiancai)下 # /api/platform/auth/* 经 Host 中间件直达,TDD 循环闭环。Task 3 将为 # /api/platform/ 前缀加中间件豁免(平台后台不依赖租户域名解析); diff --git a/app/platform/routes/stats_routes.py b/app/platform/routes/stats_routes.py new file mode 100644 index 0000000..b3ce40f --- /dev/null +++ b/app/platform/routes/stats_routes.py @@ -0,0 +1,78 @@ +"""统计 API(Phase 2 Task 9,spec §7.6): /api/platform/stats(超管,可 +?tenant= 单租户下钻)与 /api/platform/tenants/{slug}/stats(本租户管理员)。 + +聚合逻辑全在 app/platform/stats.py(纯查询函数);本层只做鉴权、日期解析 +(复用 data_routes 的 _parse_dt——ISO 日期/日期时间,日期-only 的 to 含 +当日全天,边界含,naive UTC)与 runtime 获取。 + +多租户合并纪律(**禁 ATTACH**,spec §7.6: 默认上限 10 个附加库): +逐租户库查询 → merge_stats 应用层合并。合并范围: +- 含停用租户——Task 8 偏差 4 语义(后台可见停用租户数据)在统计同样 + 适用: 停用只是聊天面 503,历史数据仍属后台可见资产; +- 跳过库文件不存在的未懒建租户——零数据等价,且不为一次统计主动建 + 空库(保持 engine 懒建纪律;与 main.lifespan 启动清扫同一 db_path. + exists() 判据,差异是清扫只扫 active、统计含停用,口径在此声明)。 +显式单租户(?tenant= 与 /tenants/{slug}/stats)沿用 data_routes 的 +_runtime_by_slug 语义: 未懒建租户首次查询触发建库(Task 8 语义,空 +统计 200 不炸)。 +""" +from fastapi import APIRouter, Depends, Query, Request + +from app.platform.auth import require_super +from app.platform.registry import TenantRuntime +from app.platform.routes.data_routes import _parse_dt, _runtime_by_slug +from app.platform.routes.tenant_routes import _tenant_guard +from app.platform.stats import collect_stats, merge_stats + +router = APIRouter(prefix="/api/platform", tags=["platform-stats"]) + + +def _bounds(from_raw: str | None, to_raw: str | None) -> tuple: + """?from/&to= → (from_dt, to_dt) naive UTC 闭区间(解析/报错同 data_routes)。""" + bound_from = _parse_dt(from_raw, "from") if from_raw else None + bound_to = _parse_dt(to_raw, "to", end_of_day=True) if to_raw else None + return bound_from, bound_to + + +async def _stats_for( + rt: TenantRuntime, bound_from, bound_to) -> dict: + """单个就绪 runtime 的统计(租户库 session 上跑全部聚合)。""" + async with rt.session_factory() as db: + return await collect_stats(db, from_dt=bound_from, to_dt=bound_to) + + +@router.get("/stats") +async def platform_stats( + request: Request, + tenant: str | None = None, + from_dt: str | None = Query(default=None, alias="from"), + to_dt: str | None = Query(default=None, alias="to"), + user=Depends(require_super)): + """平台统计(超管): 缺省全部租户合并(逐库查询应用层合并,禁 ATTACH), + ?tenant=slug 单租户下钻(未知 slug 404)。?from/&to= 闭区间筛选。""" + bound_from, bound_to = _bounds(from_dt, to_dt) + if tenant is not None: + rt = await _runtime_by_slug(request, tenant) + return await _stats_for(rt, bound_from, bound_to) + + registry = request.app.state.registry + parts = [] + for rt in registry.runtimes(): + # 合并范围口径见模块 docstring: 含停用、跳过无库文件(不建空库) + if not rt.db_path.exists(): + continue + ready = await registry.ensure_ready(rt) + parts.append(await _stats_for(ready, bound_from, bound_to)) + return merge_stats(parts) + + +@router.get("/tenants/{slug}/stats") +async def tenant_stats( + slug: str, request: Request, + from_dt: str | None = Query(default=None, alias="from"), + to_dt: str | None = Query(default=None, alias="to"), + user=Depends(_tenant_guard)): + """单租户统计(本租户管理员或超管): 查询/懒建语义同 data_routes。""" + bound_from, bound_to = _bounds(from_dt, to_dt) + rt = await _runtime_by_slug(request, slug) + return await _stats_for(rt, bound_from, bound_to) diff --git a/app/platform/stats.py b/app/platform/stats.py new file mode 100644 index 0000000..254e927 --- /dev/null +++ b/app/platform/stats.py @@ -0,0 +1,275 @@ +"""统计查询纯函数(Phase 2 Task 9,spec §7.6): 五表现有数据聚合——卡片/ +漏斗/趋势/分布/推送健康,无新表。 + +数据形态: 单租户 collect_stats(该租户 session)+ 跨租户 merge_stats(应用层 +合并)——**逐库查询、应用层合并,绝不用 SQLite ATTACH**(spec §7.6: ATTACH +默认上限 10 个附加库,租户一多就炸;本模块零 ATTACH 调用)。 + +指标口径(歧义处已定死,Phase 3 前端照此展示): +- 会话数: conversations 行数,**不要求有消息**——统计口径是"发生的会话", + 与数据视图列表"至少一条消息"(jc 列表语义)不同,两者有意并存。 +- 线索数: 该会话的 customer_profiles.raw_info 含 phone/whatsapp/email + (推送预检的"contact 三选一")任一**非 _ 前缀键**且值非空——wechat 不算 + (推送侧只认三选一);与 contact_pipeline.normalize_contacts 的归一化键 + 同名(中英文别名入库前已归一)。 +- 高意向数: evaluations.intent_level == "interested" 的会话数。 +- 推送成功率: lead_push_logs 的 success/(success+failed);skipped(预检 + 不满足必拒)与 pending(未终态)不进分母;分母 0 → None(前端展示"—")。 +- 漏斗: 会话 → 留资 → 评估完成(有 evaluations 行)→ 推送成功(该会话有 + 过 success 日志;日志自身不另受时间窗约束——会话本体已在窗内)。 +- 趋势: 按日分组,**北京日界** date(created_at, '+8 hours')——created_at + 库内为 naive UTC(models 约定),容器部署 TZ=Asia/Shanghai,业务日界以 + 北京为准(代码按 naive UTC 存储 + 固定 +8 偏移分组,不依赖进程本地时区, + 测试环境与生产同口径);无数据的日期不产出行(前端补零)。 +- 分布: intent 三级 / source / site(client_info 的 site 键,JSON 取键, + 与 data_routes 的 site 筛选同一姿势;无 site 键的会话不计)/ country + top10(IP 经 ip_service.resolve_country 逐行归国——iptocc 是内存库, + 逐行查 N 会话在几百量级毫秒级可接受,故不在 SQL 里做)。 +- 推送健康: 四状态(pending/success/failed/skipped)计数 + duplicated + (outcome 字段,200 但对方判重——不是错误)计数。 + +时间筛选: from_dt/to_dt 为 naive UTC 边界(由 stats_routes 经 data_routes + 的 _parse_dt 解析,日期-only 的 to 含当日全天,边界含)。会话锚定的指标滤 +conversations.created_at;推送锚定的指标(成功率/推送健康)滤 +lead_push_logs.created_at——两套锚点各自与本体表同窗。 +""" +from collections import Counter, defaultdict + +from sqlalchemy import ColumnElement, and_, func, or_, select + +from app.engine.models import ( + Conversation, + CustomerProfile, + Evaluation, + IntentLevel, + LeadPushLog, +) +from app.engine.services.ip_service import resolve_country + +# 线索判定的联系方式键(三选一;wechat 不算——与推送预检同口径) +CONTACT_KEYS = ("phone", "whatsapp", "email") +INTENT_KEYS = ("interested", "not_interested", "pending") +PUSH_STATUS_KEYS = ("pending", "success", "failed", "skipped") +COUNTRY_TOP_N = 10 +# 北京日界偏移(SQLite date() 修饰符): 见模块 docstring"趋势"条 +_TZ_BEIJING = "+8 hours" + + +def _empty_stats() -> dict: + """零数据形态(无租户可查/空库)——与 collect_stats 输出同构。""" + return { + "cards": { + "conversations": 0, "leads": 0, "high_intent": 0, + "push_success_rate": None}, + "funnel": { + "conversations": 0, "leads": 0, "evaluated": 0, + "push_succeeded": 0}, + "trend": [], + "distributions": { + "intent": {k: 0 for k in INTENT_KEYS}, + "source": {}, + "site": {}, + "country": []}, + "push_health": { + "pending": 0, "success": 0, "failed": 0, "skipped": 0, + "duplicated": 0}, + } + + +def _contact_any() -> ColumnElement: + """SQL 布尔: raw_info 三选一键任一非空(NULL/空白串都不算)。""" + conds = [] + for key in CONTACT_KEYS: + value = CustomerProfile.raw_info[key].as_string() + conds.append(and_(value.is_not(None), func.trim(value) != "")) + return or_(*conds) + + +def _range(column, from_dt, to_dt) -> list: + """闭区间条件列表(边界含;None 边界不加条件)。""" + conds = [] + if from_dt is not None: + conds.append(column >= from_dt) + if to_dt is not None: + conds.append(column <= to_dt) + return conds + + +def _push_rate(health: dict) -> float | None: + """success/(success+failed);分母 0 → None。""" + done = health["success"] + health["failed"] + return health["success"] / done if done else None + + +def _sorted_counts(counter: dict) -> dict: + """分布字典定序: 计数倒序、同数按 key 升序(JSON 对象保序,前端展示稳)。""" + return dict(sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))) + + +async def collect_stats( + session, *, from_dt=None, to_dt=None) -> dict: + """单租户统计: 在该租户库的 session 上跑全部聚合,输出指标 dict。 + + 只读不写;所有计数走 SQL 聚合(例外: country 逐行 IP 归国,见模块 + docstring)。""" + conv_range = _range(Conversation.created_at, from_dt, to_dt) + log_range = _range(LeadPushLog.created_at, from_dt, to_dt) + + # ---- 卡片 + 漏斗(会话锚定) ------------------------------------------------ + conversations = (await session.execute( + select(func.count()).select_from(Conversation).where(*conv_range) + )).scalar() or 0 + leads = (await session.execute( + select(func.count()) + .select_from(Conversation) + .join(CustomerProfile, + CustomerProfile.conversation_id == Conversation.id) + .where(*conv_range, _contact_any()) + )).scalar() or 0 + high_intent = (await session.execute( + select(func.count()) + .select_from(Conversation) + .join(Evaluation, Evaluation.conversation_id == Conversation.id) + .where(*conv_range, + Evaluation.intent_level == IntentLevel.interested) + )).scalar() or 0 + evaluated = (await session.execute( + select(func.count()) + .select_from(Conversation) + .join(Evaluation, Evaluation.conversation_id == Conversation.id) + .where(*conv_range) + )).scalar() or 0 + push_succeeded = (await session.execute( + select(func.count()).select_from(Conversation).where( + *conv_range, + Conversation.id.in_( + select(LeadPushLog.conversation_id) + .where(LeadPushLog.status == "success"))) + )).scalar() or 0 + + # ---- 推送健康(日志锚定) -------------------------------------------------- + health = {k: 0 for k in PUSH_STATUS_KEYS} + for status, count in (await session.execute( + select(LeadPushLog.status, func.count()) + .where(*log_range).group_by(LeadPushLog.status))).all(): + if status in health: # 未知状态不计(防御 imported/手改数据) + health[status] = count + health["duplicated"] = (await session.execute( + select(func.count()).select_from(LeadPushLog).where( + *log_range, LeadPushLog.outcome == "duplicated") + )).scalar() or 0 + + # ---- 趋势(北京日界) ------------------------------------------------------ + day = func.date(Conversation.created_at, _TZ_BEIJING) + conv_per_day = dict((await session.execute( + select(day, func.count()).where(*conv_range).group_by(day) + )).all()) + lead_stmt = ( + select(day, func.count()) + .select_from(Conversation) + .join(CustomerProfile, + CustomerProfile.conversation_id == Conversation.id) + .where(*conv_range, _contact_any()) + .group_by(day)) + lead_per_day = dict((await session.execute(lead_stmt)).all()) + trend = [ + {"date": d, "conversations": conv_per_day[d], + "leads": lead_per_day.get(d, 0)} + for d in sorted(set(conv_per_day) | set(lead_per_day)) + ] + + # ---- 分布 ----------------------------------------------------------------- + intent = {k: 0 for k in INTENT_KEYS} + for level, count in (await session.execute( + select(Evaluation.intent_level, func.count()) + .join(Conversation, Conversation.id == Evaluation.conversation_id) + .where(*conv_range).group_by(Evaluation.intent_level))).all(): + intent[level.value if hasattr(level, "value") else str(level)] = count + + source = Counter() + for src, count in (await session.execute( + select(Conversation.source, func.count()) + .where(*conv_range).group_by(Conversation.source))).all(): + source[src.value if hasattr(src, "value") else str(src)] = count + + site_expr = Conversation.client_info["site"].as_string() + site = Counter() + for site_slug, count in (await session.execute( + select(site_expr, func.count()) + .where(*conv_range, site_expr.is_not(None)).group_by(site_expr) + )).all(): + site[site_slug] = count + + # country: 逐行 IP 归国(iptocc 内存库,几百会话毫秒级——见模块 docstring) + country = Counter() + for (ip,) in (await session.execute( + select(Conversation.client_info["ip"].as_string()) + .where(*conv_range))).all(): + country[resolve_country(ip or "")] += 1 + + return { + "cards": { + "conversations": conversations, + "leads": leads, + "high_intent": high_intent, + "push_success_rate": _push_rate(health)}, + "funnel": { + "conversations": conversations, + "leads": leads, + "evaluated": evaluated, + "push_succeeded": push_succeeded}, + "trend": trend, + "distributions": { + "intent": intent, + "source": _sorted_counts(source), + "site": _sorted_counts(site), + "country": [ + {"country": name, "count": count} + for name, count in country.most_common(COUNTRY_TOP_N)]}, + "push_health": health, + } + + +def merge_stats(parts: list[dict]) -> dict: + """跨租户应用层合并(逐库结果求和;比率/排序合并后重算重排)。 + + parts 为空 → 零数据形态。合并语义: 标量求和;趋势按日期键合并; + intent/source/site 按 key 求和;country 计数合并后重取 top N。""" + if not parts: + return _empty_stats() + out = _empty_stats() + + trend = defaultdict(lambda: {"conversations": 0, "leads": 0}) + intent = {k: 0 for k in INTENT_KEYS} + source: Counter = Counter() + site: Counter = Counter() + country: Counter = Counter() + + for part in parts: + for key in ("conversations", "leads", "high_intent"): + out["cards"][key] += part["cards"][key] + for key in ("conversations", "leads", "evaluated", "push_succeeded"): + out["funnel"][key] += part["funnel"][key] + for row in part["trend"]: + trend[row["date"]]["conversations"] += row["conversations"] + trend[row["date"]]["leads"] += row["leads"] + for key, count in part["distributions"]["intent"].items(): + intent[key] = intent.get(key, 0) + count + source.update(part["distributions"]["source"]) + site.update(part["distributions"]["site"]) + for item in part["distributions"]["country"]: + country[item["country"]] += item["count"] + for key in out["push_health"]: + out["push_health"][key] += part["push_health"][key] + + out["cards"]["push_success_rate"] = _push_rate(out["push_health"]) + out["trend"] = [ + {"date": d, **counts} for d, counts in sorted(trend.items())] + out["distributions"] = { + "intent": intent, + "source": _sorted_counts(source), + "site": _sorted_counts(site), + "country": [ + {"country": name, "count": count} + for name, count in country.most_common(COUNTRY_TOP_N)]}, + return out diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..392070f --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,407 @@ +"""统计 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): + return client.post(TENANTS, headers=_auth(client), json={ + "name": f"{slug} 租户", + "slug": slug, + "profile": "jiancai", + "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") + _create(super_client, slug="jcb", admin="jcb-admin", + password="jcb-pass-1") + # jcc: 创建后从未 resolve——库文件不存在,合并视图应跳过(不建空库) + _create(super_client, slug="jcc") + + 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}, + ] + 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