风控 P3 收尾与适当性豁免额度校验 #4
@@ -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")),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user