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() == [] def _complete_trace() -> RiskWorkOrder: return work_order( disclosure_at=datetime(2026, 9, 1), confirmation_at=datetime(2026, 9, 1), recording_reference="REC-1", ) @pytest.mark.asyncio async def test_suitability_exemption_limit_breach_raises_alert() -> None: """C3→R4 属第十五条允许的越级购买,但单只持仓超过总资产 20% 仍要预警。 原先扫描侧只看"留痕是否齐全":签了揭示书、留痕齐全就直接不报 —— 于是"签了字 但买超额度"这种明确违规反而没有预警。 """ session = FakeSession( rows=[transaction()], get_values=[risk_user("C3"), product("R4"), _complete_trace()], scalar_values=[None, Decimal("100000.00"), Decimal("30000.00")], ) alerts = await RiskRuleEngine(session)._suitability_mismatch() assert len(alerts) == 1 assert alerts[0].alert_level == "中" assert "20%" in alerts[0].evidence_summary assert alerts[0].evidence_snapshot["exemption_ratio"] == "0.3" assert alerts[0].evidence_snapshot["exemption_limit"] == "0.20" @pytest.mark.asyncio async def test_suitability_exemption_within_limit_is_not_an_alert() -> None: """留痕齐全且占比未超额度 → 豁免成立,不该产生预警。""" session = FakeSession( rows=[transaction()], get_values=[risk_user("C3"), product("R4"), _complete_trace()], scalar_values=[None, Decimal("100000.00"), Decimal("10000.00")], ) assert await RiskRuleEngine(session)._suitability_mismatch() == [] @pytest.mark.asyncio async def test_suitability_exemption_missing_asset_data_does_not_create_alerts() -> None: """总资产为 0(上游未落地)时不能算成"超限"。 本项目的画像与持仓由本项目之外的流程写入,拿 0 去算会让每一笔 C3→R4 都变成 违规 —— 豁免规则就成了新的误报源。缺失只如实记录,交给研判侧提示人工确认。 """ session = FakeSession( rows=[transaction()], get_values=[risk_user("C3"), product("R4"), _complete_trace()], scalar_values=[None, Decimal("0"), Decimal("10000.00")], ) assert await RiskRuleEngine(session)._suitability_mismatch() == [] @pytest.mark.asyncio async def test_suitability_missing_trace_still_alerts_without_exemption_data() -> None: """留痕不完整这条老语义不能被新增的额度逻辑冲掉。""" session = FakeSession( rows=[transaction()], get_values=[risk_user("C3"), product("R4"), work_order()], scalar_values=[None, Decimal("0"), None], ) alerts = await RiskRuleEngine(session)._suitability_mismatch() assert len(alerts) == 1 assert "交易留痕不完整" in alerts[0].evidence_summary assert alerts[0].evidence_snapshot["exemption_data_missing"] is True @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: # confirmed_at 在库里是 **UTC**(见 app/infrastructure/db.py:15-21),而"凌晨"是 # **北京时间**概念:北京 00:00–06:00 对应 UTC 的前一日 16:00–22:00。 # 原先这里直接拿 UTC 小时当北京时间(写 0 点 / 6 点),等于在测"北京 08:00 / 14:00", # 规则修正后这些用例的语义也一并修正(docs/25 P1 #6)。 night = transaction() night.confirmed_at = datetime(2026, 9, 9, 16, 0) # 北京 09-10 00:00,凌晨起点(含) 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, 9, 22, 0) # 北京 09-10 06:00,凌晨终点(不含) 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, 9, 18, 0) # 北京 09-10 02:00,在凌晨但金额超限 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() == []