from datetime import date, datetime from decimal import Decimal import pytest from app.core.contracts import RequestContext from app.model.fund import ( FundCapitalFlow, FundCustomerProfile, FundProduct, FundRiskAlert, FundTransaction, ) from app.model.risk import RiskLoginRecord, RiskUser, RiskWorkOrder from app.service.risk_scan_service import ( RiskRuleEngine, RiskScanService, _new_alert_id, ) class ScalarRows: def __init__(self, rows): self.rows = rows def all(self): return list(self.rows) class FakeSession: def __init__(self, *, scalar_values=None, rows=None, get_values=None): self.scalar_values = list(scalar_values or []) self.rows = list(rows or []) self.get_values = list(get_values or []) self.statements = [] self.committed = False self.rolled_back = False async def scalar(self, statement): self.statements.append(statement) return self.scalar_values.pop(0) if self.scalar_values else None async def scalars(self, statement): self.statements.append(statement) return ScalarRows(self.rows) async def get(self, _model, _identifier): return self.get_values.pop(0) if self.get_values else None def add(self, _value): return None async def flush(self): return None async def commit(self): self.committed = True async def rollback(self): self.rolled_back = True def begin_nested(self): return _NestedTransaction() class _NestedTransaction: async def __aenter__(self): return self async def __aexit__(self, _exc_type, _exc, _traceback): return False def transaction() -> FundTransaction: return FundTransaction( id=100, transaction_no="TX-100", order_id=200, work_order_id=300, customer_id=1, account_id=2, product_id=3, order_side="sell", transaction_type="赎回", executed_price=Decimal("1.000000"), nav=Decimal("1.000000"), executed_quantity=Decimal("750000.0000"), shares=Decimal("750000.0000"), gross_amount=Decimal("750000.00"), amount=Decimal("750000.00"), fee_rate_snapshot=Decimal("0.000000"), fee_amount=Decimal("0.00"), net_amount=Decimal("750000.00"), quote_at=datetime(2026, 9, 10, 8, 0), quote_source="test", executed_at=datetime(2026, 9, 10, 8, 0), confirmed_at=datetime(2026, 9, 10, 8, 0), auto_confirmed=0, created_at=datetime(2026, 9, 10, 8, 0), ) def capital_flow() -> FundCapitalFlow: return FundCapitalFlow( id=400, flow_no="FLOW-400", customer_id=1, flow_type="入金", amount=Decimal("800000.00"), status="成功", settled_at=datetime(2026, 9, 8, 8, 0), occurred_at=datetime(2026, 9, 8, 8, 0), source_type="银行转入", match_status="已匹配", created_at=datetime(2026, 9, 8, 8, 0), updated_at=datetime(2026, 9, 8, 8, 0), ) def risk_user(investor_type: str = "C2") -> RiskUser: return RiskUser( id=1, user_no="CUST-001", username="customer001", user_type="CUSTOMER", investor_type=investor_type, is_professional_investor=0, professional_investor_status="未申请", fund_account_status="已开户", status="正常", created_at=datetime(2026, 1, 1), updated_at=datetime(2026, 9, 10), ) def product( risk_level: str = "R5", *, disclosure: int = 1, confirmation: int = 1, recording: int = 1, ) -> FundProduct: return FundProduct( id=3, product_code="P-001", product_name="测试产品", exchange_code="159999", product_category="股票型", risk_level=risk_level, currency="CNY", lot_size=Decimal("100.0000"), price_tick=Decimal("0.000100"), min_amount=Decimal("100.00"), single_investor_max_holding_ratio=Decimal("100.0000"), risk_disclosure_required=disclosure, second_confirmation_required=confirmation, recording_required=recording, status="在售", created_at=datetime(2026, 1, 1), updated_at=datetime(2026, 9, 10), ) def work_order( *, disclosure_at: datetime | None = None, confirmation_at: datetime | None = None, recording_reference: str | None = None, channel: str | None = "手机应用", ) -> RiskWorkOrder: return RiskWorkOrder( id=300, work_order_no="WO-300", customer_id=1, product_id=3, channel=channel, risk_disclosure_ack_at=disclosure_at, second_confirmation_at=confirmation_at, recording_reference=recording_reference, status="已提交", created_at=datetime(2026, 9, 1), updated_at=datetime(2026, 9, 10), ) def customer_profile(birth_date: date = date(1954, 1, 1)) -> FundCustomerProfile: return FundCustomerProfile( customer_id=1, trade_account="ACC-001", real_name="张三", birth_date=birth_date, investor_type="C2", total_asset=Decimal("1000000.00"), behavior_score=20, updated_at=datetime(2026, 9, 10), ) @pytest.mark.asyncio async def test_fast_in_fast_out_builds_fact_based_alert() -> None: session = FakeSession( rows=[transaction()], scalar_values=[capital_flow(), None], ) alerts = await RiskRuleEngine(session)._fast_in_fast_out() assert len(alerts) == 1 assert alerts[0].trigger_rule_codes == ["RW-003"] assert alerts[0].alert_level == "高" assert "800000" in alerts[0].evidence_summary assert alerts[0].evidence_snapshot["ratio"] == "0.9375" @pytest.mark.asyncio async def test_duplicate_rule_hit_is_suppressed() -> None: session = FakeSession( rows=[transaction()], scalar_values=[capital_flow(), 1], ) alerts = await RiskRuleEngine(session)._fast_in_fast_out() assert alerts == [] def test_same_transaction_alerts_are_merged() -> None: first = FundRiskAlert( id=1, alert_no="AL-1", customer_id=1, related_transaction_id=100, alert_type="大额快进快出", alert_level="高", trigger_rule_codes=["RW-003"], evidence_summary="摘要一", evidence_snapshot={"product_id": 3}, priority_score=98, event_status="正在发生", status="待处理", ack_status="未确认", is_escalated=0, created_at=datetime(2026, 9, 10), updated_at=datetime(2026, 9, 10), ) second = FundRiskAlert( id=2, alert_no="AL-2", customer_id=1, related_transaction_id=100, alert_type="老年客户异常大额赎回", alert_level="中", trigger_rule_codes=["RW-012"], evidence_summary="摘要二", evidence_snapshot={"age": 72}, priority_score=96, event_status="正在发生", status="待处理", ack_status="未确认", is_escalated=0, created_at=datetime(2026, 9, 10), updated_at=datetime(2026, 9, 10), ) merged = RiskRuleEngine._merge_same_transaction_alerts([first, second]) assert len(merged) == 1 assert merged[0].trigger_rule_codes == ["RW-003", "RW-012"] assert merged[0].alert_level == "高" assert merged[0].evidence_summary == "摘要一;摘要二" assert len(merged[0].evidence_snapshot["merged_alerts"]) == 2 @pytest.mark.asyncio async def test_scan_service_uses_transaction_boundary() -> None: class FakeRuleEngine: async def refresh_alerts(self): return [ FundRiskAlert( id=1, alert_no="AL-1", customer_id=1, alert_type="大额快进快出", alert_level="高", trigger_rule_codes=["RW-003"], evidence_summary="摘要", evidence_snapshot={}, priority_score=98, event_status="正在发生", status="待处理", ack_status="未确认", is_escalated=0, created_at=datetime(2026, 9, 10), updated_at=datetime(2026, 9, 10), ) ] session = FakeSession() context = RequestContext( user_id="990000002", trace_id="scan-trace", permissions=("risk:alert:scan",), data_scope="all", ) result = await RiskScanService( session, rule_engine=FakeRuleEngine(), notification_enabled=False, ).scan(context) assert result == { "message": "规则扫描完成", "created_count": 1, "high_risk_count": 1, "notification_count": 0, } assert session.committed is True assert session.rolled_back is False def test_alert_id_is_nonzero_positive() -> None: alert_id = _new_alert_id() assert 0 < alert_id < 2**63 @pytest.mark.asyncio async def test_scan_creates_high_risk_notifications_in_savepoint() -> None: class FakeRuleEngine: async def refresh_alerts(self): return [ FundRiskAlert( id=1, alert_no="AL-1", customer_id=1, alert_type="大额快进快出", alert_level="高", trigger_rule_codes=["RW-003"], evidence_summary="摘要", evidence_snapshot={}, priority_score=98, event_status="正在发生", status="待处理", ack_status="未确认", handler_id=990000002, is_escalated=0, created_at=datetime(2026, 9, 10), updated_at=datetime(2026, 9, 10), ) ] class FakeNotificationService: def __init__(self): self.calls = [] def create_in_app(self, alert, **kwargs): self.calls.append(("in_app", alert.alert_no, kwargs)) def create_mail_record(self, alert, **kwargs): self.calls.append(("mail", alert.alert_no, kwargs)) notifier = FakeNotificationService() context_value = RequestContext( user_id="990000002", trace_id="scan-trace", permissions=("risk:alert:scan",), data_scope="all", ) result = await RiskScanService( FakeSession(), rule_engine=FakeRuleEngine(), notification_service=notifier, notification_email="risk@example.com", ).scan(context_value) assert result["notification_count"] == 2 assert [call[0] for call in notifier.calls] == ["in_app", "mail"] @pytest.mark.asyncio async def test_notification_failure_does_not_rollback_scan() -> None: class FakeRuleEngine: async def refresh_alerts(self): return [ FundRiskAlert( id=1, alert_no="AL-1", customer_id=1, alert_type="大额快进快出", alert_level="高", trigger_rule_codes=["RW-003"], evidence_summary="摘要", evidence_snapshot={}, priority_score=98, event_status="正在发生", status="待处理", ack_status="未确认", is_escalated=0, created_at=datetime(2026, 9, 10), updated_at=datetime(2026, 9, 10), ) ] class FailingNotificationService: def create_in_app(self, *_args, **_kwargs): raise RuntimeError("notification failed") session = FakeSession() context_value = RequestContext( user_id="990000002", trace_id="scan-trace", permissions=("risk:alert:scan",), data_scope="all", ) result = await RiskScanService( session, rule_engine=FakeRuleEngine(), notification_service=FailingNotificationService(), ).scan(context_value) assert result["created_count"] == 1 assert result["notification_count"] == 0 assert session.committed is True assert session.rolled_back is False @pytest.mark.asyncio async def test_suitability_mismatch_high_and_medium_boundaries() -> None: session = FakeSession( rows=[transaction()], get_values=[risk_user("C2"), product("R5"), work_order()], scalar_values=[None], ) alerts = await RiskRuleEngine(session)._suitability_mismatch() assert len(alerts) == 1 assert alerts[0].alert_level == "高" session = FakeSession( rows=[transaction()], get_values=[risk_user("C4"), product("R5"), work_order()], scalar_values=[None], ) alerts = await RiskRuleEngine(session)._suitability_mismatch() assert len(alerts) == 1 assert alerts[0].alert_level == "中" @pytest.mark.asyncio async def test_suitability_mismatch_rejects_complete_trace_and_matching_level() -> None: session = FakeSession( rows=[transaction()], get_values=[ risk_user("C2"), product("R5"), work_order( disclosure_at=datetime(2026, 9, 1), confirmation_at=datetime(2026, 9, 1), recording_reference="REC-1", ), ], scalar_values=[None], ) assert await RiskRuleEngine(session)._suitability_mismatch() == [] session = FakeSession( rows=[transaction()], get_values=[risk_user("C5"), product("R5"), work_order()], scalar_values=[None], ) assert await RiskRuleEngine(session)._suitability_mismatch() == [] @pytest.mark.asyncio async def test_elderly_redemption_requires_age_amount_average_and_uncommon_device() -> None: login = RiskLoginRecord( id=500, user_id=1, login_at=datetime(2026, 9, 9), login_result="成功", device_id="DEVICE-NEW", is_common_device=0, created_at=datetime(2026, 9, 9), ) session = FakeSession( rows=[transaction()], get_values=[customer_profile()], scalar_values=[Decimal("100000.00"), login, None], ) alerts = await RiskRuleEngine(session)._elderly_redemption() assert len(alerts) == 1 assert alerts[0].trigger_rule_codes == ["RW-012"] session = FakeSession( rows=[transaction()], get_values=[customer_profile()], scalar_values=[Decimal("250000.00"), login, None], ) boundary_alerts = await RiskRuleEngine(session)._elderly_redemption() assert len(boundary_alerts) == 1 session = FakeSession( rows=[transaction()], get_values=[customer_profile()], scalar_values=[Decimal("260000.00")], ) assert await RiskRuleEngine(session)._elderly_redemption() == [] @pytest.mark.asyncio async def test_elderly_redemption_rejects_common_device() -> None: login = RiskLoginRecord( id=500, user_id=1, login_at=datetime(2026, 9, 9), login_result="成功", device_id="DEVICE-COMMON", is_common_device=1, created_at=datetime(2026, 9, 9), ) session = FakeSession( rows=[transaction()], get_values=[customer_profile()], scalar_values=[Decimal("100000.00"), login], ) assert await RiskRuleEngine(session)._elderly_redemption() == [] @pytest.mark.asyncio async def test_night_small_trade_boundaries() -> None: night = transaction() night.confirmed_at = datetime(2026, 9, 10, 0, 0) night.amount = Decimal("10000.00") session = FakeSession(rows=[night], scalar_values=[None]) alerts = await RiskRuleEngine(session)._low_risk_night_trade() assert len(alerts) == 1 regular = transaction() regular.confirmed_at = datetime(2026, 9, 10, 6, 0) session = FakeSession(rows=[regular], scalar_values=[None]) assert await RiskRuleEngine(session)._low_risk_night_trade() == [] too_large = transaction() too_large.confirmed_at = datetime(2026, 9, 10, 2, 0) too_large.amount = Decimal("10000.01") session = FakeSession(rows=[too_large], scalar_values=[None]) assert await RiskRuleEngine(session)._low_risk_night_trade() == [] @pytest.mark.asyncio async def test_auto_investment_false_positive_uses_valid_work_order() -> None: tx = transaction() tx.work_order_id = 300 session = FakeSession( rows=[tx], get_values=[work_order(channel="自动定投")], scalar_values=[None], ) alerts = await RiskRuleEngine(session)._auto_investment_false_positive() assert len(alerts) == 1 assert alerts[0].trigger_rule_codes == ["RW-018"] session = FakeSession( rows=[tx], get_values=[work_order(channel="定投")], scalar_values=[None], ) assert len(await RiskRuleEngine(session)._auto_investment_false_positive()) == 1 session = FakeSession( rows=[tx], get_values=[work_order(channel="手机应用")], scalar_values=[None], ) assert await RiskRuleEngine(session)._auto_investment_false_positive() == []