feat: 迁移奶龙风控业务模块与演示文档

This commit is contained in:
zhangshy
2026-09-10 21:03:44 +08:00
parent 6516ccb385
commit a94d5c754d
62 changed files with 10815 additions and 0 deletions
@@ -0,0 +1,121 @@
from datetime import datetime
from decimal import Decimal
from types import MappingProxyType
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.repository.fund_query_repository import CustomerScope, FundPage, FundRecord
from app.service.risk_query_service import RiskQueryService, scope_from_context
class FakeRepository:
def __init__(self) -> None:
self.page = FundPage(
entity="risk_alert",
items=(
FundRecord(
entity="risk_alert",
values=MappingProxyType(
{
"alert_no": "ALERT-001",
"customer_id": 9,
"risk_level": "高",
"amount": Decimal("100.00"),
"created_at": datetime(2026, 9, 10, 8, 0),
"rule_codes": ("RW-007",),
}
),
),
),
limit=5,
offset=0,
next_offset=5,
)
async def overview(self) -> dict:
return {
"total": 2,
"levels": {"低": 1, "中": 0, "高": 1},
"pending": 1,
"overdue": 0,
"high_priority": list(self.page.items),
}
async def list_alerts(self, **_kwargs) -> FundPage:
return self.page
async def get_alert_detail(self, _alert_no: str) -> FundRecord | None:
return None
def context(**updates) -> RequestContext:
values = {
"user_id": "990000002",
"trace_id": "trace",
"permissions": ("risk:alert:read",),
"data_scope": "all",
}
values.update(updates)
return RequestContext(**values)
def test_cursor_round_trip_and_invalid_values() -> None:
assert decode_offset_cursor(encode_offset_cursor(20)) == 20
assert decode_offset_cursor(None) == 0
with pytest.raises(InvalidCursorError):
decode_offset_cursor("not-a-cursor")
with pytest.raises(InvalidCursorError):
decode_offset_cursor(encode_offset_cursor(1) + "x")
def test_query_schema_enforces_business_page_sizes() -> None:
assert RiskAlertPageQuery(limit=5).limit == 5
assert RiskEvidencePageQuery(limit=10).limit == 10
with pytest.raises(ValueError):
RiskAlertPageQuery(limit=6)
with pytest.raises(ValueError):
RiskEvidencePageQuery(limit=11)
def test_scope_from_context_is_fail_closed() -> None:
assert scope_from_context(context()).is_unrestricted
assert scope_from_context(context(data_scope="self")).is_denied
scope = scope_from_context(
context(data_scope="own_customers", customer_ids=("9", "10"))
)
assert scope == CustomerScope.for_customers({9, 10})
@pytest.mark.asyncio
async def test_overview_maps_levels_and_serializes_high_priority() -> None:
service = RiskQueryService(None, repository=FakeRepository())
result = await service.overview(context())
assert result["levels"] == {"高风险": 1, "中风险": 0, "低风险": 1}
assert result["high_priority"][0]["customer_id"] == "9"
assert result["high_priority"][0]["amount"] == "100.00"
assert result["high_priority"][0]["created_at"] == "2026-09-10T08:00:00Z"
@pytest.mark.asyncio
async def test_alert_page_returns_opaque_cursor() -> None:
service = RiskQueryService(None, repository=FakeRepository())
result = await service.list_alerts(context(), RiskAlertPageQuery(limit=5))
assert result["has_more"] is True
assert decode_offset_cursor(result["next_cursor"]) == 5
assert result["items"][0]["rule_codes"] == ["RW-007"]
@pytest.mark.asyncio
async def test_missing_alert_detail_is_hidden() -> None:
service = RiskQueryService(None, repository=FakeRepository())
with pytest.raises(GenericResourceNotFoundError):
await service.get_alert_detail(context(), "ALERT-NOT-FOUND")