docs/25 P1 #6。根因是"库内存 UTC naive"这个约定在**业务判断层**没被遵守, 而展示层其实已经是对的(risk_daily_report_service.py:418 按配置时区转换)。 1. 新增 app/core/timeutil.py 作为统一换算入口:约定库内 UTC naive,提供 local_hour / local_date / local_day_bounds(后两者用于查库时必须返回 UTC naive, 否则区间与库内值整体错开 8 小时),带时区的入参按其自身时区解释。 2. risk_scan_service.py:248:0 <= confirmed_at.hour < 6 → local_hour(...)。 原先 [0,6) UTC 被当成"凌晨",实际是北京时间 08:00-14:00,整条 「凌晨时段小额操作」规则判的是上午。 3. risk_judgement_service.py:238/243:判断**与展示**都换算。展示不改的话, 风控专员看到的时刻与直觉差 8 小时,无法与客户核对。 4. risk_daily_report_service.py:89:日界改用 local_day_bounds(按北京时间自然日, 再折回 UTC naive)。原先按 UTC 日期切日,北京 08:00 前生成的日报统计窗口跨零点。 测试: - 新增 tests/unit/core/test_timeutil.py(8 条),含"UTC 凌晨 0-6 点不是北京凌晨" 这一缺陷复现,以及"日界必须返回 UTC naive"。 - 改写 test_risk_scan_service.py::test_night_small_trade_boundaries:它原本就拿 UTC 小时构造数据(写 0 点/6 点),等于在测北京 08:00/14:00;语义一并修正为 北京 00:00(含)与 06:00(不含)两个边界,三个边界场景保持不变。 ruff / mypy(135 文件) / 603 unit+contract 全绿。
473 lines
19 KiB
Python
473 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_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
|
||
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, ""))
|