风控列表游标绑定用户与查询条件(docs/25 遗留 P3)

docs/05 §3.8 要求游标绑定用户、查询条件、排序字段和方向,此前实现只把 offset
用 base64 包了一层:任何登录用户拿到别人的游标都能继续翻,换个筛选条件也能继续翻
(偏移量对不上就静默返回错页)。现在游标里携带 SHA-256 指纹:

- 指纹口径 = user_id + data_scope/customer_ids + 查询条件(排除 limit/cursor)
- 刻意排除 limit:它是分页参数、不是查询条件,算进去只会让翻页时改页大小失效
- /evidence/{source} 的 source 是路径参数,单独并入指纹,否则 customers 的
  游标能直接拿去翻 products
- 指纹不符一律 InvalidCursorError -> 400 INVALID_CURSOR(docs/05 §3.6)

新增 2 个单测:换用户/换筛选/换 data_scope 失效、改 limit 仍有效、
不同证据类型游标不互通。
This commit is contained in:
2026-09-11 14:05:37 +08:00
parent e8b075bc7f
commit a572c09a5c
4 changed files with 142 additions and 23 deletions
+51 -7
View File
@@ -7,7 +7,7 @@ import pytest
from app.api.schemas.risk import RiskAlertPageQuery, RiskEvidencePageQuery
from app.core.contracts import RequestContext
from app.core.errors import GenericResourceNotFoundError, InvalidCursorError
from app.core.risk_cursor import decode_offset_cursor, encode_offset_cursor
from app.core.risk_cursor import cursor_binding, decode_offset_cursor, encode_offset_cursor
from app.repository.fund_query_repository import CustomerScope, FundPage, FundRecord
from app.service.risk_query_service import RiskQueryService, scope_from_context
@@ -64,12 +64,15 @@ def context(**updates) -> RequestContext:
def test_cursor_round_trip_and_invalid_values() -> None:
assert decode_offset_cursor(encode_offset_cursor(20)) == 20
assert decode_offset_cursor(None) == 0
binding = cursor_binding(user_id="990000002", filters={"keyword": None})
assert (
decode_offset_cursor(encode_offset_cursor(20, binding=binding), binding=binding) == 20
)
assert decode_offset_cursor(None, binding=binding) == 0
with pytest.raises(InvalidCursorError):
decode_offset_cursor("not-a-cursor")
decode_offset_cursor("not-a-cursor", binding=binding)
with pytest.raises(InvalidCursorError):
decode_offset_cursor(encode_offset_cursor(1) + "x")
decode_offset_cursor(encode_offset_cursor(1, binding=binding) + "x", binding=binding)
def test_query_schema_enforces_business_page_sizes() -> None:
@@ -105,14 +108,55 @@ async def test_overview_maps_levels_and_serializes_high_priority() -> None:
@pytest.mark.asyncio
async def test_alert_page_returns_opaque_cursor() -> None:
service = RiskQueryService(None, repository=FakeRepository())
query = RiskAlertPageQuery(limit=5)
result = await service.list_alerts(context(), RiskAlertPageQuery(limit=5))
result = await service.list_alerts(context(), query)
assert result["has_more"] is True
assert decode_offset_cursor(result["next_cursor"]) == 5
binding = RiskQueryService._binding(context(), query)
assert decode_offset_cursor(result["next_cursor"], binding=binding) == 5
assert result["items"][0]["rule_codes"] == ["RW-007"]
def test_cursor_is_bound_to_user_and_filters() -> None:
"""§3.8:游标绑定用户与查询条件,换个人或换个筛选条件都必须失效。"""
query = RiskAlertPageQuery(limit=5)
raw = encode_offset_cursor(5, binding=RiskQueryService._binding(context(), query))
assert decode_offset_cursor(raw, binding=RiskQueryService._binding(context(), query)) == 5
with pytest.raises(InvalidCursorError):
decode_offset_cursor(
raw,
binding=RiskQueryService._binding(context(user_id="990000003"), query),
)
with pytest.raises(InvalidCursorError):
other_filter = RiskAlertPageQuery(limit=5, keyword="张三")
decode_offset_cursor(
raw,
binding=RiskQueryService._binding(context(), other_filter),
)
# data_scope 收紧后旧游标同样失效
with pytest.raises(InvalidCursorError):
decode_offset_cursor(
raw,
binding=RiskQueryService._binding(
context(data_scope="own_customers", customer_ids=("9",)), query
),
)
def test_cursor_ignores_page_size_and_other_filters_do_not_collide() -> None:
"""翻页时改 limit 不该让游标失效;不同证据类型之间不能互相串用游标。"""
base = RiskEvidencePageQuery(limit=10)
binding = RiskQueryService._binding(context(), base, "customers")
again = RiskQueryService._binding(context(), RiskEvidencePageQuery(limit=10), "customers")
other = RiskQueryService._binding(context(), RiskEvidencePageQuery(limit=10), "products")
assert binding == again
assert binding != other
@pytest.mark.asyncio
async def test_missing_alert_detail_is_hidden() -> None:
service = RiskQueryService(None, repository=FakeRepository())