接 b3da1b6。上一条只改了凌晨规则与日报日界,剩下几处一并收掉: 1. risk_query_service.py:77-78:REST 的 start_time/end_time 是**裸 datetime**, 原先原样透传去比库内 UTC 列,而 Agent 路径本来就带时区 (risk_natural_language.py:117)——同一条筛选条件在界面与对话里会查出不同结果。 timeutil 新增 from_local:裸值按**北京时间**解释(面向中国客户的业务系统, 填表人的预期就是本地时间),带时区的按其自身时区处理。它与 to_utc_naive 的区别 正在裸值上:取库里的值用后者,接客户端输入用这个。 2. risk_scan_service.py 与 risk_judgement_service.py 的 _age:一处用 UTC 日期、 一处用服务器 date.today(),生日边界上同一客户会差一岁、65 岁阈值可能翻面。 统一走 local_date(北京时间)。 3. risk_daily_report_service.py:122 的 report_date 与 :244 的 created_today: 原先取 UTC 日期,北京 08:00 之前会把"今天新增的预警"算成昨天。 ruff / mypy(135 文件) / 603 unit+contract 全绿。
475 lines
19 KiB
Python
475 lines
19 KiB
Python
"""风控规则扫描与预警生成 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.core.timeutil import local_date, local_hour
|
||
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
|
||
# 必须按**北京时间**判断"凌晨":库里存 UTC,直接取 .hour 会让
|
||
# [0,6) UTC 变成北京 08:00–14:00,整条规则判的是上午(docs/25 P1 #6)。
|
||
and 0 <= local_hour(transaction.confirmed_at) < 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
|
||
# 用**北京时间**的今天:原先用 UTC 日期,而研判侧用服务器 date.today(),
|
||
# 两处口径不一致会让同一客户在生日边界上差一岁(docs/25 P1 #6)。
|
||
today = local_date(datetime.now(UTC))
|
||
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, ""))
|