From 38787a006f91f86380e076c693c30f7d2851ef9f Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Fri, 25 Sep 2026 16:34:52 +0800 Subject: [PATCH] fix(notifications): deterministic tiebreaker for paginated notification log list Same-class gap as e369508 (alerts list): single-key ORDER BY created_at DESC plus offset/limit lets MySQL filesort return an unstable order inside same-second tie groups, so offset pagination duplicates and skips rows. Append secondary key id DESC, aligning with app/api/alerts.py, app/api/cash.py and app/api/data_quality.py. Entity-scope caliber untouched: parent-key join attribution, alert_id IS NULL rows still excluded, policy/note wording unchanged. Local commit only - no push (release gated by ASK-20260925-04). --- backend/app/api/notifications.py | 7 +- ...fications_pagination_stability_20260925.py | 216 ++++++++++++++++++ 2 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_notifications_pagination_stability_20260925.py diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index 6b553821..d292a302 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -124,8 +124,13 @@ def list_logs(page: int = 1, .join(KPIAlert, NotificationLog.alert_id == KPIAlert.id) .filter(KPIAlert.entity_id == entity_id)) total = scoped.count() + # 排序补**确定性次键** `id DESC`(任务 0-P3-project-20260925-cma-same-class-pagination-determinism-fullstack): + # 通知日志由预警推送批量写入,同一秒多行时 MySQL filesort 在并列组内顺序不受控 ⇒ 单键 + # `created_at DESC` + offset/limit 会让偏移分页重复并漏行。两键后偏移分页成为全序分页。 + # 同族先例:app/api/alerts.py(e369508)、app/api/cash.py:165、app/api/data_quality.py:230。 + # 本行**不改**任何账套收口口径(join 归属 / `alert_id IS NULL` 不返回 / policy+note 文案)。 logs = scoped.order_by( - NotificationLog.created_at.desc() + NotificationLog.created_at.desc(), NotificationLog.id.desc() ).offset((page - 1) * 20).limit(20).all() return { "total": total, diff --git a/backend/tests/test_notifications_pagination_stability_20260925.py b/backend/tests/test_notifications_pagination_stability_20260925.py new file mode 100644 index 00000000..7d3bf329 --- /dev/null +++ b/backend/tests/test_notifications_pagination_stability_20260925.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +"""`GET /api/cma/notifications/logs` 通知历史分页确定性回归(任务 0-P3-project-20260925-cma-same-class-pagination-determinism-fullstack) + +缺陷(与 `app/api/alerts.py::list_alerts` 同构,2026-09-25 项目Bot 实读现件确认): +`app/api/notifications.py::list_logs` 用 `ORDER BY created_at DESC` **单键** + `offset/limit` +分页;`notification_logs` 由预警推送**批量写入**,同一秒多行 ⇒ MySQL filesort 在并列组内顺序 +不受控,偏移分页会**重复并漏行**(同秒内后写入者可能被前页吞掉、或同一行跨页出现两次)。 + +修复:排序补**确定性次键** `id DESC` ⇒ `created_at DESC, id DESC`,偏移分页成为全序分页, +逐页遍历覆盖全部 total 行且零重复。先例(同族两键):`app/api/alerts.py`(e369508)、 +`app/api/cash.py:165`、`app/api/data_quality.py:230`。 + +本套件判据(改前 RED / 改后 GREEN): + · T1 唯一性:逐页取满后 `唯一 id 数 == total` 且 `重复 == 0`; + · T2 确定性次键:并列 `created_at` 组内 id **严格降序**(改前同一夹具下为插入序 = 升序 → RED); + · T3 逐页序列 == 「一次性全量取」(无分页)按 (created_at desc, id desc) 的序列,逐项相同; + · T4 负对照(非回归):账套收口口径未动 —— 他账套(经父键归属)行不可见、 + `alert_id IS NULL` 行仍不返回、`policy`/`note` 文案不变; + · T5 负对照(非回归):跨时间戳仍「新→旧」。 + +夹具口径照 `tests/test_alerts_pagination_stability_20260925.py`(同秒 60+ 行 + 跨时间戳若干行)。 +""" +from datetime import datetime, timedelta + +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models import KPIAlert, NotificationLog +from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi + +PAGE_SIZE = 20 # app/api/notifications.py 硬编码 limit(20) + +#: 固定「同秒」时间戳 —— 全部并列行共用,复刻生产「预警推送批量写入、同秒 created_at」形态 +SAME_SECOND = datetime(2026, 9, 20, 7, 0, 42) + +POLICY_TEXT = "alert-linked-only" + + +def _alert(db: Session, kpi_id: int, entity_id: int = 1) -> KPIAlert: + a = KPIAlert( + kpi_id=kpi_id, + entity_id=entity_id, + alert_level="yellow", + alert_message="通知分页夹具", + status="pending", + created_at=SAME_SECOND, + ) + db.add(a) + db.commit() + db.refresh(a) + return a + + +def _log(db: Session, alert_id, **kw) -> NotificationLog: + """一条通知日志;`alert_id=None` = 无可归属父键(口径:不返回)""" + defaults = { + "alert_id": alert_id, + "channel": "wecom", + "recipient": "renzong", + "title": "预警推送", + "content": "分页夹具", + "status": "sent", + "created_at": SAME_SECOND, + } + defaults.update(kw) + row = NotificationLog(**defaults) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def _walk(client: TestClient, token: str) -> dict: + """逐页调用真实端点,直到某页不足 PAGE_SIZE 行(= 前端/调用方遍历姿势)。""" + url = "/api/cma/notifications/logs" + pages, seen, dups, rows = [], [], [], [] + page = 1 + total = None + policy = note = None + while True: + resp = client.get(f"{url}?page={page}", headers=auth_header(token)) + assert resp.status_code == 200, resp.text + body = resp.json() + if total is None: + total = body["total"] + policy = body.get("policy") + note = body.get("note") + else: + assert body["total"] == total, "翻页过程中 total 漂移(口径/过滤不一致)" + ids = [r["id"] for r in body["data"]] + for pos, r in enumerate(body["data"]): + if r["id"] in seen: + dups.append({"id": r["id"], "page": page, "pos": pos}) + else: + seen.append(r["id"]) + rows.append(r) + pages.append(ids) + if len(ids) < PAGE_SIZE: + break + page += 1 + assert page < 100, "分页遍历未终止(保险丝)" + return {"total": total, "pages": pages, "unique": seen, "dups": dups, + "rows": rows, "policy": policy, "note": note} + + +def _tie_groups(rows): + groups = {} + for r in rows: + groups.setdefault(str(r["created_at"]), []).append(r["id"]) + return groups + + +class TestNotificationsLogsPaginationStability: + """分页可及性 + 确定性次键(判据主体;改前 T2 应为 RED)""" + + def test_t1_t2_t3_tie_heavy_batch_all_rows_reachable_and_deterministic( + self, client: TestClient, db: Session): + """61 条同秒 + 5 条跨时间戳:逐页取满 ⇒ 唯一==total、零重复、并列组 id 降序、与全量序列一致""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db) + alert = _alert(db, kpi.id) + + # 61 条同秒(>3 页,且每页必然出现「并列组跨界」)+ 5 条更早的(跨时间戳排序正对照) + tie_ids = [_log(db, alert.id).id for _ in range(61)] + older_ids = [_log(db, alert.id, + created_at=SAME_SECOND - timedelta(hours=1)).id for _ in range(5)] + total_expected = 66 + + w = _walk(client, token) + assert w["total"] == total_expected, w["total"] + + # T1 唯一性与重复 + assert len(set(w["unique"])) == w["total"], ( + f"逐页遍历唯一 id 数 {len(set(w['unique']))} != total {w['total']};重复 {w['dups']}") + assert w["dups"] == [], f"偏移分页出现重复行:{w['dups']}" + assert len(w["unique"]) == w["total"], "逐页取到的行数应等于 total" + + # T2 确定性次键:并列组内 id 严格降序 + for ca, ids in _tie_groups(w["rows"]).items(): + if len(ids) > 1: + assert ids == sorted(ids, reverse=True), ( + f"created_at={ca} 并列组内未按 id 降序(分页顺序不确定):{ids}") + + # T3 逐页拼接序列 == 一次性全量取(不同查询路径:单次全量 ORM 查询,显式两键排序) + full = (db.query(NotificationLog) + .join(KPIAlert, NotificationLog.alert_id == KPIAlert.id) + .filter(KPIAlert.entity_id == 1) + .order_by(NotificationLog.created_at.desc(), NotificationLog.id.desc()) + .all()) + assert [r["id"] for r in w["rows"]] == [l.id for l in full], "逐页序列与全量排序序列不一致" + # 全量序列 = 5 条跨时间戳(新→旧)+ 61 条同秒(id 降序) + assert [r["id"] for r in w["rows"]][:5] == sorted(tie_ids, reverse=True)[:5] + assert set(w["unique"]) == set(tie_ids) | set(older_ids) + + def test_t4_entity_scope_and_null_parent_key_exclusion_unchanged( + self, client: TestClient, db: Session): + """负对照:账套收口口径未动 —— 他账套行不可见、`alert_id IS NULL` 行不返回、policy/note 不变""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db) + + mine_alert = _alert(db, kpi.id, entity_id=1) + other_alert = _alert(db, kpi.id, entity_id=2) + + mine = [_log(db, mine_alert.id).id for _ in range(25)] + other = [_log(db, other_alert.id).id for _ in range(25)] + orphan = [_log(db, None).id for _ in range(5)] # 无父键(如 OKR 复盘提醒形态) + + w = _walk(client, token) + assert w["total"] == len(mine), "账套收口被改:total 不再等于本账套可归属行数" + assert not (set(other) & set(w["unique"])), "他账套(经父键归属)的行泄漏进本账套列表" + assert not (set(orphan) & set(w["unique"])), "`alert_id IS NULL` 的行被返回(违反宁少勿错口径)" + assert set(w["unique"]) == set(mine) + assert w["policy"] == POLICY_TEXT, f"policy 文案被改:{w['policy']}" + assert w["note"] and "无父键的历史通知" in w["note"], f"note 文案被改:{w['note']}" + + def test_t5_cross_timestamp_order_newest_first_unchanged( + self, client: TestClient, db: Session): + """负对照:跨时间戳仍「新→旧」(时间戳互不相同 ⇒ 无并列,改前改后都应通过)""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db) + alert = _alert(db, kpi.id) + + older = [_log(db, alert.id, created_at=SAME_SECOND - timedelta(days=2)).id + for _ in range(10)] + newer = [_log(db, alert.id, created_at=SAME_SECOND + timedelta(hours=i + 1)).id + for i in range(6)] + expected_head = list(reversed(newer)) # 最新(+6h)在最前 + + w = _walk(client, token) + assert w["total"] == len(older) + len(newer) + assert [r["id"] for r in w["rows"]][:6] == expected_head, "跨时间戳未按新→旧排序" + assert set(w["unique"]) == set(older) | set(newer) + + def test_t6_page_window_and_deep_page_reachable(self, client: TestClient, db: Session): + """分页窗口本身未变:每页 ≤20 行、深页可读、同一行只在唯一一页出现""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db) + alert = _alert(db, kpi.id) + + ids = [_log(db, alert.id).id for _ in range(45)] + w = _walk(client, token) + + assert w["total"] == 45 + assert [len(p) for p in w["pages"]] == [20, 20, 5], f"分页窗口异常:{[len(p) for p in w['pages']]}" + assert len(w["unique"]) == 45 + assert set(w["unique"]) == set(ids) + + seen_pages = {} + for idx, p in enumerate(w["pages"], start=1): + for row_id in p: + assert row_id not in seen_pages, f"行 {row_id} 同时出现在页 {seen_pages[row_id]} 与页 {idx}" + seen_pages[row_id] = idx