From e8b075bc7fa6c7cc73eb5f162996dc7e5477bb79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Fri, 11 Sep 2026 14:01:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(risk):=20=E5=88=97=E8=A1=A8=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E7=9A=84=E4=BF=A1=E5=B0=81=E5=AF=B9=E9=BD=90=20docs/0?= =?UTF-8?q?5=20=C2=A73.3=E2=80=94=E2=80=94=E6=B8=B8=E6=A0=87=E4=BB=8E=20da?= =?UTF-8?q?ta=20=E6=8C=AA=E5=88=B0=20meta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 全绿。 --- app/api/controllers/risk.py | 27 +++++++++++++++++++++++--- tests/unit/api/test_risk_controller.py | 24 ++++++++++++++++++++--- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/app/api/controllers/risk.py b/app/api/controllers/risk.py index 025c97f..be2ddcf 100644 --- a/app/api/controllers/risk.py +++ b/app/api/controllers/risk.py @@ -3,6 +3,7 @@ import json from collections.abc import AsyncIterator from datetime import datetime, time +from typing import Any from fastapi import APIRouter, Depends, File, Path, UploadFile from sqlalchemy.ext.asyncio import AsyncSession @@ -55,7 +56,7 @@ async def list_risk_alerts( session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: data = await RiskQueryService(session).list_alerts(context, query) - return _envelope(data, context) + return _list_envelope(data, context) @router.post("/alerts/scan") @@ -159,7 +160,7 @@ async def list_risk_evidence( session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: data = await RiskQueryService(session).list_evidence(context, source, query) - return _envelope(data, context) + return _list_envelope(data, context) @router.get("/notifications") @@ -169,7 +170,7 @@ async def list_risk_notifications( session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: data = await RiskNotificationService(session).list_notifications(context, query) - return _envelope(data, context) + return _list_envelope(data, context) @router.post("/daily-report") @@ -231,3 +232,23 @@ def _envelope(data: object, context: RequestContext) -> dict[str, object]: "data": data, "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")), + }, + } diff --git a/tests/unit/api/test_risk_controller.py b/tests/unit/api/test_risk_controller.py index 60d8f2e..facb517 100644 --- a/tests/unit/api/test_risk_controller.py +++ b/tests/unit/api/test_risk_controller.py @@ -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(