From 2289b2720c3b97843dc88dfc48e740c6fa024fdc Mon Sep 17 00:00:00 2001 From: guke Date: Wed, 8 Jul 2026 17:52:40 +0800 Subject: [PATCH] fix(analytics): id-tiebreak snapshot ordering + rates/cn_day tests + breakdown ValueError --- app/admin/repositories/analytics_health.py | 21 ++++++------- tests/test_analytics_health.py | 36 +++++++++++++++++++++- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/app/admin/repositories/analytics_health.py b/app/admin/repositories/analytics_health.py index 0a0f44c..17f7bd0 100644 --- a/app/admin/repositories/analytics_health.py +++ b/app/admin/repositories/analytics_health.py @@ -9,7 +9,7 @@ from __future__ import annotations from collections import defaultdict from datetime import UTC, datetime -from sqlalchemy import and_, func, select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core import rewards @@ -31,7 +31,7 @@ def diff_snapshots(rows: list[dict]) -> list[dict]: out: list[dict] = [] for group in parts.values(): - group.sort(key=lambda r: r["created_at"]) + group.sort(key=lambda r: (r["created_at"], r.get("id", 0))) prev = {k: 0 for k in _COUNTS} for r in group: deltas = {f"d_{k}": max(0, int(r[k]) - prev[k]) for k in _COUNTS} @@ -74,7 +74,7 @@ def _sum_deltas(deltas: list[dict]) -> dict: def _fetch_rows(db: Session, date_from: datetime, date_to: datetime) -> list[dict]: """取 [from, to) 区间行 + 每分区在 from 左侧的最后一条基线行(供第一条区间增量做差)。""" cols = ( - H.device_id, H.epoch_id, E.event, H.created_at, + H.id, H.device_id, H.epoch_id, E.event, H.created_at, H.app_ver, H.oem, H.os, E.attempted, E.drop_capture, E.delivered, E.drop_undelivered, ) @@ -83,8 +83,10 @@ def _fetch_rows(db: Session, date_from: datetime, date_to: datetime) -> list[dic .where(H.created_at >= date_from, H.created_at < date_to) ).mappings().all() + # 注:基线子查询无下界扫 from 左侧全量(spec §7 已接受的取舍;量级变大再上物化 rollup)。 + # 用 max(id) 而非 max(created_at) 选"最新一条":id 严格单调,避免 SQLite 秒级时间戳撞车时选歧义。 sub = ( - select(H.device_id, H.epoch_id, E.event, func.max(H.created_at).label("mx")) + select(H.device_id, H.epoch_id, E.event, func.max(H.id).label("max_id")) .join(E, E.snapshot_id == H.id) .where(H.created_at < date_from) .group_by(H.device_id, H.epoch_id, E.event) @@ -92,13 +94,7 @@ def _fetch_rows(db: Session, date_from: datetime, date_to: datetime) -> list[dic ) baseline = db.execute( select(*cols).join(E, E.snapshot_id == H.id).join( - sub, - and_( - sub.c.device_id == H.device_id, - sub.c.epoch_id == H.epoch_id, - sub.c.event == E.event, - sub.c.mx == H.created_at, - ), + sub, sub.c.max_id == H.id ) ).mappings().all() @@ -128,7 +124,8 @@ def trend(db: Session, date_from: datetime, date_to: datetime) -> list[dict]: def breakdown(db: Session, date_from: datetime, date_to: datetime, dim: str) -> list[dict]: - assert dim in ("event", "app_ver", "oem") + if dim not in ("event", "app_ver", "oem"): + raise ValueError(f"invalid dim: {dim!r}") deltas = _in_range_deltas(db, date_from, date_to) by_key: dict[str, list[dict]] = defaultdict(list) for d in deltas: diff --git a/tests/test_analytics_health.py b/tests/test_analytics_health.py index 02333c7..812d345 100644 --- a/tests/test_analytics_health.py +++ b/tests/test_analytics_health.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime, timezone -from app.admin.repositories.analytics_health import diff_snapshots +from app.admin.repositories.analytics_health import _cn_day, _rates, diff_snapshots def _row(device, epoch, event, ts, **cum) -> dict: @@ -52,3 +52,37 @@ def test_diff_clamps_negative_on_reorder() -> None: ] out = sorted(diff_snapshots(rows), key=lambda r: r["created_at"]) assert out[1]["d_attempted"] == 0 + + +def test_rates_computes_both_formulas() -> None: + out = _rates({"attempted": 150, "drop_capture": 0, "delivered": 140, "drop_undelivered": 10}) + assert out["track_success_rate"] == 1.0 + assert out["report_success_rate"] == 140 / 150 + + +def test_rates_zero_denominator_yields_none() -> None: + out = _rates({"attempted": 0, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0}) + assert out["track_success_rate"] is None + assert out["report_success_rate"] is None + + +def test_cn_day_beijing_boundary() -> None: + # UTC 15:59 → 北京 23:59 同日;UTC 16:00 → 北京 次日 00:00 + assert _cn_day(datetime(2026, 7, 1, 15, 59, tzinfo=timezone.utc)) == "2026-07-01" + assert _cn_day(datetime(2026, 7, 1, 16, 0, tzinfo=timezone.utc)) == "2026-07-02" + + +def test_diff_same_timestamp_ordered_by_id() -> None: + ts = datetime(2026, 7, 1, 1, 0, tzinfo=timezone.utc) + # 相同 created_at,乱序传入(id=2 在前);应按 id 排序 → id=1(cum100) 在前 + rows = [ + {"id": 2, "device_id": "d", "epoch_id": "e", "event": "vp", "created_at": ts, + "app_ver": "v", "oem": "o", "os": "s", + "attempted": 150, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0}, + {"id": 1, "device_id": "d", "epoch_id": "e", "event": "vp", "created_at": ts, + "app_ver": "v", "oem": "o", "os": "s", + "attempted": 100, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0}, + ] + out = diff_snapshots(rows) + assert out[0]["d_attempted"] == 100 # id=1 sorts first → its cumulative + assert out[1]["d_attempted"] == 50 # id=2 second → 150-100