fix(risk): 列表接口的信封对齐 docs/05 §3.3——游标从 data 挪到 meta

docs/05 §3.3 的列表样例是 data 为**纯数组**、
ext_cursor 与 has_more 放在 meta 里,
并明确「业务接口不得增加其他顶层字段」。而 RiskQueryService._page 返回的
{items, next_cursor, has_more} 被**整体塞进 data** —— 游标因此出现在**业务数据**里,
meta 只剩 trace_id,两处都不符合契约。

改动:
- 新增 _list_envelope(page, context):把 _page 的结构拆成 data = items、
  meta = {trace_id, next_cursor, has_more}。**service 侧不动** —— 它继续返回那个内部
  结构,只是不再直接当 data 用。
- 3 个列表端点改用它:/alerts、/evidence/{source}、/notifications。
  非列表端点(overview、详情、各类写操作)保持原样,不带游标。

**影响调用方**:这是接口形状变更。组员若写了前端读 data.items,需要改成读 data、
并从 meta 取分页元数据。改动依据是 docs/05 这个唯一权威接口文档(§20 也要求实现与
文档同步)。**合并时要提醒组员。**

**实测**(9002 身份):
- /alerts?limit=2 → 顶层键 ['data','meta']、data 是 list(2 条真实数据)、
  meta 键 ['has_more','next_cursor','trace_id']
- /evidence/customers 与 /notifications 同形状
- 对照 /overview(非列表)→ meta 只有 trace_id、不带游标

**测试**:3 处断言从 data["items"] 改为 data;/notifications 那处补上对 meta 的断言;
新增 test_list_endpoints_follow_the_documented_envelope,直接断言"data 是纯数组、
meta 恰好三个键、顶层恰好 data/meta",把 §3.3 的契约钉住。

过程里踩了两个自己的坑:① 忘了 rom typing import Any(与 customer_service.py 同一失误,
被 ruff/mypy 当场抓住);② 漏了 /evidence/{source} 这个列表端点,是测试先失败才发现的。

ruff / mypy(136 文件) / 640 unit+contract / 29 integration 全绿。
This commit is contained in:
2026-09-11 14:01:16 +08:00
parent d896a906cd
commit e8b075bc7f
2 changed files with 45 additions and 6 deletions
+21 -3
View File
@@ -190,7 +190,7 @@ def test_alert_and_detail_routes_bind_parameters(monkeypatch) -> None:
detail = client.get("/api/v1/risk/alerts/ALERT-001")
assert alerts.status_code == 200
assert alerts.json()["data"]["items"][0]["alert_no"] == "ALERT-001"
assert alerts.json()["data"][0]["alert_no"] == "ALERT-001"
assert detail.status_code == 200
assert detail.json()["data"]["alert"]["alert_no"] == "ALERT-001"
@@ -201,7 +201,7 @@ def test_evidence_route_and_page_limit_are_enforced(monkeypatch) -> None:
invalid = client.get("/api/v1/risk/evidence/customers?limit=11")
assert valid.status_code == 200
assert valid.json()["data"]["items"] == [{"source": "customers"}]
assert valid.json()["data"] == [{"source": "customers"}]
assert invalid.status_code == 422
@@ -278,12 +278,30 @@ def test_notification_query_uses_notification_schema(monkeypatch) -> None:
invalid = client.get("/api/v1/risk/notifications?limit=11")
assert response.status_code == 200
assert response.json()["data"]["items"] == [
assert response.json()["data"] == [
{"notification_id": "N-001", "alert_no": "ALERT-001"}
]
# 列表资源的分页元数据必须在 `meta` 里(docs/05 §3.3),而不是混进 `data`
assert "next_cursor" in response.json()["meta"]
assert "has_more" in response.json()["meta"]
assert invalid.status_code == 422
def test_list_endpoints_follow_the_documented_envelope(monkeypatch) -> None:
"""`data` 是纯数组、游标与 has_more 在 `meta` —— docs/05 §3.3 的列表样例。
原先 `_page` 的 `{items, next_cursor, has_more}` 被整体塞进 `data`,游标因此出现在
**业务数据**里,而 §3.3 明确「业务接口不得增加其他顶层字段」。
"""
with authenticated_client(monkeypatch) as client:
body = client.get("/api/v1/risk/alerts?limit=5").json()
assert isinstance(body["data"], list), "data 必须是纯数组"
assert "items" not in body["data"] if isinstance(body["data"], dict) else True
assert set(body["meta"]) == {"trace_id", "next_cursor", "has_more"}
assert set(body) == {"data", "meta"}, "不得增加其他顶层字段"
def test_daily_report_generate_stream_and_mail(monkeypatch) -> None:
with authenticated_client(monkeypatch) as client:
generated = client.post(