feat: 统计API(卡片/漏斗/趋势/分布/推送健康,逐库聚合禁ATTACH)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user