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
+24 -3
View File
@@ -3,6 +3,7 @@
import json import json
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import datetime, time from datetime import datetime, time
from typing import Any
from fastapi import APIRouter, Depends, File, Path, UploadFile from fastapi import APIRouter, Depends, File, Path, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -55,7 +56,7 @@ async def list_risk_alerts(
session: AsyncSession = Depends(get_session), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008
) -> dict[str, object]: ) -> dict[str, object]:
data = await RiskQueryService(session).list_alerts(context, query) data = await RiskQueryService(session).list_alerts(context, query)
return _envelope(data, context) return _list_envelope(data, context)
@router.post("/alerts/scan") @router.post("/alerts/scan")
@@ -159,7 +160,7 @@ async def list_risk_evidence(
session: AsyncSession = Depends(get_session), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008
) -> dict[str, object]: ) -> dict[str, object]:
data = await RiskQueryService(session).list_evidence(context, source, query) data = await RiskQueryService(session).list_evidence(context, source, query)
return _envelope(data, context) return _list_envelope(data, context)
@router.get("/notifications") @router.get("/notifications")
@@ -169,7 +170,7 @@ async def list_risk_notifications(
session: AsyncSession = Depends(get_session), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008
) -> dict[str, object]: ) -> dict[str, object]:
data = await RiskNotificationService(session).list_notifications(context, query) data = await RiskNotificationService(session).list_notifications(context, query)
return _envelope(data, context) return _list_envelope(data, context)
@router.post("/daily-report") @router.post("/daily-report")
@@ -231,3 +232,23 @@ def _envelope(data: object, context: RequestContext) -> dict[str, object]:
"data": data, "data": data,
"meta": {"trace_id": context.trace_id}, "meta": {"trace_id": context.trace_id},
} }
def _list_envelope(page: dict[str, Any], context: RequestContext) -> dict[str, object]:
"""列表资源的信封(docs/05 §3.3)。
§3.3 的列表样例是 `data` 为**纯数组**、游标与 `has_more` 放在 `meta` 里,并且明确
「业务接口不得增加其他顶层字段」。而 `RiskQueryService._page` 返回的是
`{items, next_cursor, has_more}` —— 整体塞进 `data` 后,游标跑进了**业务数据**里、
`meta` 只剩 trace_id,两处都不符合契约。
这里统一拆包;service 侧不必改(它继续返回那个内部结构,只是不再直接当 `data` 用)。
"""
return {
"data": page.get("items") or [],
"meta": {
"trace_id": context.trace_id,
"next_cursor": page.get("next_cursor"),
"has_more": bool(page.get("has_more")),
},
}
+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") detail = client.get("/api/v1/risk/alerts/ALERT-001")
assert alerts.status_code == 200 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.status_code == 200
assert detail.json()["data"]["alert"]["alert_no"] == "ALERT-001" 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") invalid = client.get("/api/v1/risk/evidence/customers?limit=11")
assert valid.status_code == 200 assert valid.status_code == 200
assert valid.json()["data"]["items"] == [{"source": "customers"}] assert valid.json()["data"] == [{"source": "customers"}]
assert invalid.status_code == 422 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") invalid = client.get("/api/v1/risk/notifications?limit=11")
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["data"]["items"] == [ assert response.json()["data"] == [
{"notification_id": "N-001", "alert_no": "ALERT-001"} {"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 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: def test_daily_report_generate_stream_and_mail(monkeypatch) -> None:
with authenticated_client(monkeypatch) as client: with authenticated_client(monkeypatch) as client:
generated = client.post( generated = client.post(