修复高风险预警邮件通知并补充测试
This commit is contained in:
@@ -98,7 +98,7 @@ async def scan_risk_alerts(
|
||||
async with mysql_scan_lock() as acquired:
|
||||
if not acquired:
|
||||
raise RiskScanBusyError("规则扫描正在执行,请稍后重试")
|
||||
return await RiskScanService(inner).scan(context)
|
||||
return await RiskScanService.from_settings(inner).scan(context)
|
||||
|
||||
data = await _idempotent_write(
|
||||
session, context, key, "POST /api/v1/risk/alerts/scan", {}, run
|
||||
|
||||
@@ -117,6 +117,9 @@ class Settings(BaseSettings):
|
||||
risk_scan_run_immediately: bool = False
|
||||
risk_scan_retry_limit: int = Field(default=2, ge=0, le=5)
|
||||
risk_scan_poll_seconds: float = Field(default=30, gt=0)
|
||||
risk_alert_mail_enabled: bool = False
|
||||
risk_alert_mail_dry_run: bool = True
|
||||
risk_alert_mail_recipients: str = ""
|
||||
# 投顾灰度默认关闭,只有显式开启并配置客户白名单后才限制客户流量。
|
||||
# 管理员角色始终可进入,便于审核、发布和故障处置。
|
||||
advisor_rollout_enabled: bool = False
|
||||
|
||||
@@ -3,18 +3,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
from collections.abc import Mapping
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formatdate, make_msgid
|
||||
|
||||
from app.core.contracts import RequestContext
|
||||
from app.service.authorization_service import AuthorizationService
|
||||
from app.service.risk_smtp_mail_service import (
|
||||
RiskSmtpConfigurationError,
|
||||
RiskSmtpMailService,
|
||||
)
|
||||
|
||||
|
||||
class RiskDailyReportMailService:
|
||||
def __init__(self, *, environment: Mapping[str, str] | None = None) -> None:
|
||||
self.environment = environment or os.environ
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
smtp_service: RiskSmtpMailService | None = None,
|
||||
) -> None:
|
||||
self.environment = environment if environment is not None else os.environ
|
||||
self.smtp_service = smtp_service or RiskSmtpMailService(
|
||||
environment=environment
|
||||
)
|
||||
|
||||
async def send(
|
||||
self,
|
||||
@@ -36,28 +45,10 @@ class RiskDailyReportMailService:
|
||||
return {"status": "disabled", "recipient_count": len(recipients)}
|
||||
if _enabled(self.environment, "RISK_DAILY_REPORT_MAIL_DRY_RUN", default=True):
|
||||
return {"status": "dry_run", "recipient_count": len(recipients)}
|
||||
host = self.environment.get("RISK_SMTP_HOST", "").strip()
|
||||
sender = self.environment.get("RISK_SMTP_SENDER", "").strip()
|
||||
if not host or not sender:
|
||||
try:
|
||||
return await self.smtp_service.send(recipients, subject, content)
|
||||
except RiskSmtpConfigurationError:
|
||||
return {"status": "configuration_error", "recipient_count": len(recipients)}
|
||||
message = EmailMessage()
|
||||
message["From"] = sender
|
||||
message["To"] = ", ".join(recipients)
|
||||
message["Subject"] = subject
|
||||
message["Date"] = formatdate(localtime=True)
|
||||
message["Message-ID"] = make_msgid(domain="risk-report.local")
|
||||
message.set_content(content)
|
||||
port = int(self.environment.get("RISK_SMTP_PORT", "465"))
|
||||
use_ssl = _enabled(self.environment, "RISK_SMTP_USE_SSL", default=True)
|
||||
timeout = float(self.environment.get("RISK_SMTP_TIMEOUT_SECONDS", "30"))
|
||||
smtp_class = smtplib.SMTP_SSL if use_ssl else smtplib.SMTP
|
||||
with smtp_class(host, port, timeout=timeout) as connection:
|
||||
username = self.environment.get("RISK_SMTP_USERNAME", "").strip()
|
||||
password = self.environment.get("RISK_SMTP_PASSWORD", "")
|
||||
if username:
|
||||
connection.login(username, password)
|
||||
connection.send_message(message)
|
||||
return {"status": "sent", "recipient_count": len(recipients)}
|
||||
|
||||
|
||||
def _enabled(environment: Mapping[str, str], name: str, *, default: bool = False) -> bool:
|
||||
|
||||
@@ -93,6 +93,22 @@ class RiskNotificationService:
|
||||
self.session.add(notification)
|
||||
return notification
|
||||
|
||||
def mark_mail_sent(self, notification: FundRiskNotification) -> None:
|
||||
"""邮件发送成功后回写通知状态。"""
|
||||
notification.send_status = "已发送"
|
||||
notification.sent_at = _now()
|
||||
notification.fail_reason = None
|
||||
|
||||
def mark_mail_failed(
|
||||
self,
|
||||
notification: FundRiskNotification,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""邮件发送失败后保留通知记录并记录安全失败原因。"""
|
||||
notification.send_status = "发送失败"
|
||||
notification.sent_at = None
|
||||
notification.fail_reason = reason.strip()[:500] or "邮件发送失败"
|
||||
|
||||
def create_high_risk_records(
|
||||
self,
|
||||
alerts: list[FundRiskAlert],
|
||||
|
||||
@@ -16,6 +16,7 @@ from uuid import uuid4
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.contracts import RequestContext
|
||||
from app.core.errors import AgentError, ConflictAgentError
|
||||
from app.core.timeutil import local_date, local_hour
|
||||
@@ -26,11 +27,17 @@ from app.model.fund import (
|
||||
FundHolding,
|
||||
FundProduct,
|
||||
FundRiskAlert,
|
||||
FundRiskNotification,
|
||||
FundTransaction,
|
||||
)
|
||||
from app.model.risk import RiskLoginRecord, RiskUser, RiskWorkOrder
|
||||
from app.service.authorization_service import AuthorizationService
|
||||
from app.service.risk_notification_service import RiskNotificationService
|
||||
from app.service.risk_smtp_mail_service import (
|
||||
RiskSmtpConfigurationError,
|
||||
RiskSmtpMailService,
|
||||
RiskSmtpSendError,
|
||||
)
|
||||
|
||||
HIGH_RISK = "高"
|
||||
MEDIUM_RISK = "中"
|
||||
@@ -510,6 +517,8 @@ class RiskScanService:
|
||||
notification_enabled: bool = True,
|
||||
notification_email: str | None = None,
|
||||
mail_enabled: bool = False,
|
||||
mail_dry_run: bool = False,
|
||||
smtp_mail_service: RiskSmtpMailService | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.rule_engine = rule_engine or RiskRuleEngine(session)
|
||||
@@ -517,6 +526,31 @@ class RiskScanService:
|
||||
self.notification_enabled = notification_enabled
|
||||
self.notification_email = notification_email
|
||||
self.mail_enabled = mail_enabled
|
||||
self.mail_dry_run = mail_dry_run
|
||||
self.smtp_mail_service = smtp_mail_service or RiskSmtpMailService()
|
||||
|
||||
@classmethod
|
||||
def from_settings(
|
||||
cls,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
rule_engine: RiskRuleEngine | None = None,
|
||||
notification_service: RiskNotificationService | None = None,
|
||||
) -> RiskScanService:
|
||||
"""从环境配置创建扫描服务,供手工扫描和定时扫描共用。"""
|
||||
settings = get_settings()
|
||||
recipients = _parse_recipients(settings.risk_alert_mail_recipients)
|
||||
notification_email = recipients[0] if recipients else None
|
||||
if settings.risk_alert_mail_enabled and not notification_email:
|
||||
logger.warning("高风险邮件通知已启用,但未配置收件人")
|
||||
return cls(
|
||||
session,
|
||||
rule_engine=rule_engine,
|
||||
notification_service=notification_service,
|
||||
notification_email=notification_email,
|
||||
mail_enabled=settings.risk_alert_mail_enabled and bool(notification_email),
|
||||
mail_dry_run=settings.risk_alert_mail_dry_run,
|
||||
)
|
||||
|
||||
async def scan(self, context: RequestContext) -> dict[str, int | str]:
|
||||
await AuthorizationService.require(context, "risk:alert:scan")
|
||||
@@ -572,7 +606,7 @@ class RiskScanService:
|
||||
)
|
||||
count += 1
|
||||
if self.notification_email:
|
||||
self.notification_service.create_mail_record(
|
||||
notification = self.notification_service.create_mail_record(
|
||||
alert,
|
||||
receiver_email=self.notification_email,
|
||||
title=title,
|
||||
@@ -580,11 +614,48 @@ class RiskScanService:
|
||||
mail_enabled=self.mail_enabled,
|
||||
)
|
||||
count += 1
|
||||
if self.mail_enabled:
|
||||
await self._deliver_mail(
|
||||
alert,
|
||||
notification,
|
||||
title,
|
||||
)
|
||||
return count, ""
|
||||
except Exception as error:
|
||||
logger.exception("高风险通知记录创建失败,预警扫描继续提交")
|
||||
return 0, f"{type(error).__name__}: {error}"[:200]
|
||||
|
||||
async def _deliver_mail(
|
||||
self,
|
||||
alert: FundRiskAlert,
|
||||
notification: FundRiskNotification,
|
||||
title: str,
|
||||
) -> None:
|
||||
"""发送高风险通知邮件,失败只回写通知记录,不影响预警落库。"""
|
||||
if self.mail_dry_run:
|
||||
self.notification_service.mark_mail_failed(
|
||||
notification,
|
||||
"邮件发送处于 dry-run 模式",
|
||||
)
|
||||
return
|
||||
try:
|
||||
result = await self.smtp_mail_service.send(
|
||||
[self.notification_email or ""],
|
||||
title,
|
||||
f"预警编号:{alert.alert_no};{alert.evidence_summary}",
|
||||
)
|
||||
except (RiskSmtpConfigurationError, RiskSmtpSendError) as error:
|
||||
logger.warning("高风险预警邮件发送失败:alert_no=%s error=%s", alert.alert_no, error)
|
||||
self.notification_service.mark_mail_failed(notification, str(error))
|
||||
except Exception:
|
||||
logger.exception("高风险预警邮件发送发生未预期异常:alert_no=%s", alert.alert_no)
|
||||
self.notification_service.mark_mail_failed(notification, "邮件发送发生未预期异常")
|
||||
else:
|
||||
if result.get("status") == "sent":
|
||||
self.notification_service.mark_mail_sent(notification)
|
||||
else:
|
||||
self.notification_service.mark_mail_failed(notification, "邮件发送未完成")
|
||||
|
||||
|
||||
def _new_alert_id() -> int:
|
||||
"""生成非零 63 位正整数;预警表主键不自增。"""
|
||||
@@ -614,3 +685,14 @@ def _level_value(level: str, prefix: str) -> int | None:
|
||||
"""
|
||||
digits = level.strip().upper().removeprefix(prefix.upper())
|
||||
return int(digits) if digits.isdigit() else None
|
||||
|
||||
|
||||
def _parse_recipients(value: str | None) -> list[str]:
|
||||
"""解析逗号或分号分隔的邮件收件人配置。"""
|
||||
if not value:
|
||||
return []
|
||||
return [
|
||||
recipient.strip()
|
||||
for recipient in value.replace(";", ",").split(",")
|
||||
if recipient.strip()
|
||||
]
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""风控模块共用的 SMTP 邮件发送服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import smtplib
|
||||
from collections.abc import Mapping
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formatdate, make_msgid, parseaddr
|
||||
|
||||
|
||||
class RiskSmtpConfigurationError(ValueError):
|
||||
"""SMTP 配置缺失或格式无效。"""
|
||||
|
||||
|
||||
class RiskSmtpSendError(RuntimeError):
|
||||
"""SMTP 邮件发送失败。"""
|
||||
|
||||
|
||||
class RiskSmtpMailService:
|
||||
"""使用 ``RISK_SMTP_*`` 配置发送风控邮件。"""
|
||||
|
||||
def __init__(self, *, environment: Mapping[str, str] | None = None) -> None:
|
||||
self.environment = environment if environment is not None else os.environ
|
||||
|
||||
async def send(
|
||||
self,
|
||||
recipients: list[str],
|
||||
subject: str,
|
||||
content: str,
|
||||
) -> dict[str, object]:
|
||||
return await asyncio.to_thread(
|
||||
self._send_sync,
|
||||
recipients,
|
||||
subject,
|
||||
content,
|
||||
)
|
||||
|
||||
def _send_sync(
|
||||
self,
|
||||
recipients: list[str],
|
||||
subject: str,
|
||||
content: str,
|
||||
) -> dict[str, object]:
|
||||
normalized = self._validated_recipients(recipients)
|
||||
host = self.environment.get("RISK_SMTP_HOST", "").strip()
|
||||
sender = self.environment.get("RISK_SMTP_SENDER", "").strip()
|
||||
if not host or not sender:
|
||||
raise RiskSmtpConfigurationError("SMTP 发件配置缺失")
|
||||
if not _is_email(sender):
|
||||
raise RiskSmtpConfigurationError("SMTP 发件地址格式无效")
|
||||
if not subject.strip() or not content.strip():
|
||||
raise RiskSmtpConfigurationError("邮件主题和正文不能为空")
|
||||
if "\r" in subject or "\n" in subject:
|
||||
raise RiskSmtpConfigurationError("邮件主题包含非法换行")
|
||||
|
||||
message = EmailMessage()
|
||||
message["From"] = sender
|
||||
message["To"] = ", ".join(normalized)
|
||||
message["Subject"] = subject
|
||||
message["Date"] = formatdate(localtime=True)
|
||||
message["Message-ID"] = make_msgid(domain="risk-alert.local")
|
||||
message.set_content(content)
|
||||
|
||||
port = int(self.environment.get("RISK_SMTP_PORT", "465"))
|
||||
use_ssl = _enabled(self.environment, "RISK_SMTP_USE_SSL", default=True)
|
||||
timeout = float(self.environment.get("RISK_SMTP_TIMEOUT_SECONDS", "30"))
|
||||
smtp_class = smtplib.SMTP_SSL if use_ssl else smtplib.SMTP
|
||||
try:
|
||||
with smtp_class(host, port, timeout=timeout) as connection:
|
||||
username = self.environment.get("RISK_SMTP_USERNAME", "").strip()
|
||||
password = self.environment.get("RISK_SMTP_PASSWORD", "")
|
||||
if username:
|
||||
connection.login(username, password)
|
||||
connection.send_message(message)
|
||||
except smtplib.SMTPAuthenticationError as error:
|
||||
raise RiskSmtpSendError("SMTP 身份验证失败") from error
|
||||
except smtplib.SMTPRecipientsRefused as error:
|
||||
raise RiskSmtpSendError("收件人被邮件服务器拒绝") from error
|
||||
except smtplib.SMTPSenderRefused as error:
|
||||
raise RiskSmtpSendError("发件人被邮件服务器拒绝") from error
|
||||
except TimeoutError as error:
|
||||
raise RiskSmtpSendError("SMTP 连接超时") from error
|
||||
except OSError as error:
|
||||
raise RiskSmtpSendError(f"SMTP 连接失败:{error}") from error
|
||||
except smtplib.SMTPException as error:
|
||||
raise RiskSmtpSendError("SMTP 邮件发送失败") from error
|
||||
return {"status": "sent", "recipient_count": len(normalized)}
|
||||
|
||||
@staticmethod
|
||||
def _validated_recipients(recipients: list[str]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in recipients:
|
||||
recipient = value.strip()
|
||||
if not _is_email(recipient):
|
||||
raise RiskSmtpConfigurationError("收件人地址格式无效")
|
||||
lowered = recipient.lower()
|
||||
if lowered not in seen:
|
||||
normalized.append(recipient)
|
||||
seen.add(lowered)
|
||||
if not normalized:
|
||||
raise RiskSmtpConfigurationError("收件人不能为空")
|
||||
return normalized
|
||||
|
||||
|
||||
def _enabled(environment: Mapping[str, str], name: str, *, default: bool = False) -> bool:
|
||||
value = environment.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _is_email(value: str) -> bool:
|
||||
_, parsed = parseaddr(value)
|
||||
return bool(value and parsed == value and "@" in value and len(value) <= 254)
|
||||
@@ -46,7 +46,7 @@ async def default_scan_executor() -> dict[str, int | str]:
|
||||
portal="worker",
|
||||
)
|
||||
async with SessionFactory() as session:
|
||||
return await RiskScanService(session).scan(context)
|
||||
return await RiskScanService.from_settings(session).scan(context)
|
||||
|
||||
|
||||
async def default_audit_writer(status: str, detail: dict[str, Any]) -> None:
|
||||
|
||||
Reference in New Issue
Block a user