"""风控规则扫描与预警生成 Service。 扫描只读取交易、资金、持仓、客户、产品和工单事实;写入仅限预警和审计。 规则扫描不负责通知外发,也不在 Web 进程中启动定时任务。 """ from __future__ import annotations import asyncio import logging from datetime import UTC, date, datetime, timedelta from decimal import Decimal from typing import Any from uuid import uuid4 from sqlalchemy import Select, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.contracts import RequestContext from app.core.errors import AgentError, ConflictAgentError from app.model.audit import InteractionAudit from app.model.fund import ( FundCapitalFlow, FundCustomerProfile, FundProduct, FundRiskAlert, FundTransaction, ) from app.model.risk import RiskLoginRecord, RiskUser, RiskWorkOrder from app.service.authorization_service import AuthorizationService from app.service.risk_notification_service import RiskNotificationService HIGH_RISK = "高" MEDIUM_RISK = "中" LOW_RISK = "低" RISK_ORDER = {LOW_RISK: 0, MEDIUM_RISK: 1, HIGH_RISK: 2} _scan_lock = asyncio.Lock() logger = logging.getLogger(__name__) class RiskScanBusyError(ConflictAgentError): """同一进程内已有扫描任务执行。""" class RiskScanError(AgentError): """规则扫描失败。""" code = "AGENT_INTERNAL_ERROR" status_code = 500 class RiskRuleEngine: """五条当前启用风控规则的只读判定和预警构造。""" def __init__(self, session: AsyncSession) -> None: self.session = session self._handler_id: int | None = None async def refresh_alerts(self) -> list[FundRiskAlert]: self._handler_id = await self._risk_operator_id() alerts: list[FundRiskAlert] = [] alerts.extend(await self._fast_in_fast_out()) alerts.extend(await self._suitability_mismatch()) alerts.extend(await self._elderly_redemption()) alerts.extend(await self._low_risk_night_trade()) alerts.extend(await self._auto_investment_false_positive()) alerts = self._merge_same_transaction_alerts(alerts) for alert in alerts: self.session.add(alert) self.session.add(InteractionAudit( actor_type="system", target_customer_id=alert.customer_id, portal="api", action_type="risk_alert_created", detail={"alert_no": alert.alert_no, "rule_codes": alert.trigger_rule_codes}, created_at=datetime.now(UTC).replace(tzinfo=None), )) await self.session.flush() return alerts async def _fast_in_fast_out(self) -> list[FundRiskAlert]: alerts: list[FundRiskAlert] = [] transactions = await self._scalars( select(FundTransaction).where(FundTransaction.transaction_type == "赎回") ) for transaction in transactions: if transaction.confirmed_at is None or transaction.amount is None: continue capital = await self.session.scalar( select(FundCapitalFlow) .where( FundCapitalFlow.customer_id == transaction.customer_id, FundCapitalFlow.flow_type == "入金", FundCapitalFlow.status == "成功", FundCapitalFlow.settled_at.is_not(None), FundCapitalFlow.settled_at <= transaction.confirmed_at, FundCapitalFlow.settled_at >= transaction.confirmed_at - timedelta(days=3), ) .order_by(FundCapitalFlow.settled_at.desc()) .limit(1) ) if capital is None or capital.amount <= 0: continue ratio = Decimal(transaction.amount) / Decimal(capital.amount) if ratio >= Decimal("0.8") and transaction.amount >= 500000 and not await self._exists( transaction.id, "RW-003" ): alerts.append(self._build_alert( transaction=transaction, alert_type="大额快进快出", level=HIGH_RISK, rules=["RW-003"], summary=( f"3 日内入金 {capital.amount:.0f} 元后赎回 " f"{transaction.amount:.0f} 元,赎回比例 {ratio:.2%}。" ), priority=98, event_status="正在发生", evidence={ "product_id": transaction.product_id, "capital_flow_id": capital.id, "flow_no": capital.flow_no, "ratio": str(ratio), }, )) return alerts async def _suitability_mismatch(self) -> list[FundRiskAlert]: alerts: list[FundRiskAlert] = [] transactions = await self._scalars( select(FundTransaction).where(FundTransaction.transaction_type == "申购") ) for transaction in transactions: customer = await self.session.get(RiskUser, transaction.customer_id) product = await self.session.get(FundProduct, transaction.product_id) work_order = ( await self.session.get(RiskWorkOrder, transaction.work_order_id) if transaction.work_order_id is not None else None ) if ( customer is None or product is None or not customer.investor_type or await self._exists(transaction.id, "RW-007") ): continue gap = _level_value(product.risk_level, "R") - _level_value(customer.investor_type, "C") missing_trace = ( ( product.risk_disclosure_required and ( work_order is None or work_order.risk_disclosure_ack_at is None ) ) or ( product.second_confirmation_required and ( work_order is None or work_order.second_confirmation_at is None ) ) or ( product.recording_required and (work_order is None or not work_order.recording_reference) ) ) if gap > 0 and missing_trace: level = HIGH_RISK if gap >= 2 else MEDIUM_RISK alerts.append(self._build_alert( transaction=transaction, alert_type="适当性错配", level=level, rules=["RW-007"], summary=( f"{customer.investor_type} 客户购买 " f"{product.risk_level} 产品,交易留痕不完整。" ), priority=90 if level == HIGH_RISK else 70, event_status="刚刚发生", evidence={ "product_id": product.id, "level_gap": gap, "work_order_id": transaction.work_order_id, }, )) return alerts async def _elderly_redemption(self) -> list[FundRiskAlert]: alerts: list[FundRiskAlert] = [] transactions = await self._scalars( select(FundTransaction).where(FundTransaction.transaction_type == "赎回") ) for transaction in transactions: if transaction.confirmed_at is None or transaction.amount is None: continue profile = await self.session.get(FundCustomerProfile, transaction.customer_id) age = _age(profile.birth_date) if profile else 0 if profile is None or age < 65 or transaction.amount < 300000: continue average = await self.session.scalar( select(func.avg(FundTransaction.amount)).where( FundTransaction.customer_id == transaction.customer_id, FundTransaction.id != transaction.id, FundTransaction.confirmed_at >= transaction.confirmed_at - timedelta(days=365), ) ) if average is None or transaction.amount < Decimal(average) * 3: continue login = await self.session.scalar( select(RiskLoginRecord) .where( RiskLoginRecord.user_id == transaction.customer_id, RiskLoginRecord.login_result == "成功", RiskLoginRecord.login_at <= transaction.confirmed_at, ) .order_by(RiskLoginRecord.login_at.desc()) .limit(1) ) if login is not None and not login.is_common_device and not await self._exists( transaction.id, "RW-012" ): alerts.append(self._build_alert( transaction=transaction, alert_type="老年客户异常大额赎回", level=HIGH_RISK, rules=["RW-012"], summary=( f"{age} 岁客户赎回 {transaction.amount:.0f} 元," "超过历史均值且使用非常用设备。" ), priority=96, event_status="正在发生", evidence={ "product_id": transaction.product_id, "age": age, "device_id": login.device_id, }, )) return alerts async def _low_risk_night_trade(self) -> list[FundRiskAlert]: alerts: list[FundRiskAlert] = [] for transaction in await self._scalars(select(FundTransaction)): if ( transaction.confirmed_at is not None and 0 <= transaction.confirmed_at.hour < 6 and transaction.amount is not None and transaction.amount <= 10000 and not await self._exists(transaction.id, "RW-015") ): alerts.append(self._build_alert( transaction=transaction, alert_type="非正常时段小额操作", level=LOW_RISK, rules=["RW-015"], summary=f"凌晨时段发生 {transaction.amount:.0f} 元交易,金额较小。", priority=20, event_status="盘后预警", evidence={ "product_id": transaction.product_id, "hour": transaction.confirmed_at.hour, }, )) return alerts async def _auto_investment_false_positive(self) -> list[FundRiskAlert]: alerts: list[FundRiskAlert] = [] transactions = await self._scalars( select(FundTransaction).where(FundTransaction.work_order_id.is_not(None)) ) for transaction in transactions: work_order = await self.session.get(RiskWorkOrder, transaction.work_order_id) if ( work_order is not None and work_order.channel in {"定投", "自动定投"} and not await self._exists(transaction.id, "RW-018") ): alerts.append(self._build_alert( transaction=transaction, alert_type="频繁交易初筛", level=LOW_RISK, rules=["RW-018"], summary="近 30 天交易频率较高,但交易来自有效定投工单。", priority=10, event_status="盘后预警", evidence={ "product_id": transaction.product_id, "work_order_no": work_order.work_order_no, "channel": work_order.channel, }, )) return alerts def _build_alert( self, *, transaction: FundTransaction, alert_type: str, level: str, rules: list[str], summary: str, priority: int, event_status: str, evidence: dict[str, object], ) -> FundRiskAlert: now = datetime.now(UTC).replace(tzinfo=None) return FundRiskAlert( id=_new_alert_id(), alert_no=f"AL{uuid4().hex[:12].upper()}", customer_id=transaction.customer_id, related_transaction_id=transaction.id, related_order_id=transaction.order_id, related_work_order_id=transaction.work_order_id, alert_type=alert_type, alert_level=level, trigger_rule_codes=rules, evidence_summary=summary, evidence_snapshot=evidence, priority_score=priority, event_status=event_status, status="待处理", ack_status="未确认", handler_id=self._handler_id, due_at=now + timedelta(minutes=30) if level == HIGH_RISK else None, is_escalated=0, created_at=now, updated_at=now, ) async def _exists(self, transaction_id: int, rule_code: str) -> bool: return await self.session.scalar( select(FundRiskAlert.id).where( FundRiskAlert.related_transaction_id == transaction_id, FundRiskAlert.trigger_rule_codes.contains([rule_code]), ) ) is not None async def _risk_operator_id(self) -> int | None: value = await self.session.scalar( select(RiskUser.id) .where(RiskUser.employee_role == "risk_operator", RiskUser.status == "正常") .order_by(RiskUser.id.asc()) .limit(1) ) return int(value) if value is not None else None async def _scalars(self, statement: Select[Any]) -> list[Any]: result = await self.session.scalars(statement) return list(result.all()) @staticmethod def _merge_same_transaction_alerts(alerts: list[FundRiskAlert]) -> list[FundRiskAlert]: grouped: dict[int | None, list[FundRiskAlert]] = {} for alert in alerts: grouped.setdefault(alert.related_transaction_id, []).append(alert) merged: list[FundRiskAlert] = [] for transaction_id, items in grouped.items(): if transaction_id is None or len(items) == 1: merged.extend(items) continue primary = max(items, key=lambda item: item.priority_score) primary.trigger_rule_codes = list( dict.fromkeys(code for item in items for code in item.trigger_rule_codes) ) primary.evidence_summary = ";".join(item.evidence_summary for item in items) primary.evidence_snapshot = { "product_id": primary.evidence_snapshot.get("product_id"), "merged_alerts": [ {"alert_type": item.alert_type, "evidence": item.evidence_snapshot} for item in items ], } primary.alert_level = max( (item.alert_level for item in items), key=RISK_ORDER.__getitem__, ) primary.priority_score = max(item.priority_score for item in items) merged.append(primary) return merged class RiskScanService: def __init__( self, session: AsyncSession, *, rule_engine: RiskRuleEngine | None = None, notification_service: RiskNotificationService | None = None, notification_enabled: bool = True, notification_email: str | None = None, mail_enabled: bool = False, ) -> None: self.session = session self.rule_engine = rule_engine or RiskRuleEngine(session) self.notification_service = notification_service or RiskNotificationService(session) self.notification_enabled = notification_enabled self.notification_email = notification_email self.mail_enabled = mail_enabled async def scan(self, context: RequestContext) -> dict[str, int | str]: await AuthorizationService.require(context, "risk:alert:scan") if _scan_lock.locked(): raise RiskScanBusyError("规则扫描正在执行,请稍后重试") async with _scan_lock: try: alerts = await self.rule_engine.refresh_alerts() notification_count = await self._create_notifications(alerts) await self.session.commit() except Exception as error: await self.session.rollback() raise RiskScanError("规则扫描失败") from error return { "message": "规则扫描完成", "created_count": len(alerts), "high_risk_count": sum(alert.alert_level == HIGH_RISK for alert in alerts), "notification_count": notification_count, } async def _create_notifications(self, alerts: list[FundRiskAlert]) -> int: if not self.notification_enabled: return 0 high_risk = [alert for alert in alerts if alert.alert_level == HIGH_RISK] if not high_risk: return 0 try: async with self.session.begin_nested(): count = 0 for alert in high_risk: title = f"高风险预警:{alert.alert_type}" self.notification_service.create_in_app( alert, receiver_user_id=alert.handler_id, title=title, content=alert.evidence_summary, ) count += 1 if self.notification_email: self.notification_service.create_mail_record( alert, receiver_email=self.notification_email, title=title, content=alert.evidence_summary, mail_enabled=self.mail_enabled, ) count += 1 return count except Exception: logger.exception("高风险通知记录创建失败,预警扫描继续提交") return 0 def _new_alert_id() -> int: """生成非零 63 位正整数;预警表主键不自增。""" return uuid4().int & ((1 << 63) - 1) or 1 def _age(birth_date: date | None) -> int: if birth_date is None: return 0 today = datetime.now(UTC).date() return today.year - birth_date.year - ( (today.month, today.day) < (birth_date.month, birth_date.day) ) def _level_value(level: str, prefix: str) -> int: return int(level.replace(prefix, ""))