From eb4e895e4e9e6f8d7cb0277ddee1264c666d6c21 Mon Sep 17 00:00:00 2001
From: zhangshy <994452054@qq.com>
Date: Sat, 12 Sep 2026 15:56:50 +0800
Subject: [PATCH 1/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=AB=98=E9=A3=8E?=
=?UTF-8?q?=E9=99=A9=E9=A2=84=E8=AD=A6=E9=82=AE=E4=BB=B6=E9=80=9A=E7=9F=A5?=
=?UTF-8?q?=E5=B9=B6=E8=A1=A5=E5=85=85=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 13 +-
app/api/controllers/risk.py | 2 +-
app/core/config.py | 3 +
app/service/risk_daily_report_mail_service.py | 43 +++---
app/service/risk_notification_service.py | 16 +++
app/service/risk_scan_service.py | 84 +++++++++++-
app/service/risk_smtp_mail_service.py | 117 ++++++++++++++++
app/worker/risk_scan_scheduler.py | 2 +-
tests/unit/api/test_risk_controller.py | 4 +
.../unit/service/test_risk_scan_alert_mail.py | 125 ++++++++++++++++++
.../service/test_risk_scan_notification.py | 18 +++
.../service/test_risk_smtp_mail_service.py | 71 ++++++++++
12 files changed, 464 insertions(+), 34 deletions(-)
create mode 100644 app/service/risk_smtp_mail_service.py
create mode 100644 tests/unit/service/test_risk_scan_alert_mail.py
create mode 100644 tests/unit/service/test_risk_smtp_mail_service.py
diff --git a/.env.example b/.env.example
index ba55b2c..360e066 100644
--- a/.env.example
+++ b/.env.example
@@ -57,13 +57,16 @@ RISK_SCAN_POLL_SECONDS=30
RISK_EVIDENCE_DIR=storage/risk_evidence
RISK_EVIDENCE_MAX_FILE_SIZE_MB=10
-RISK_DAILY_REPORT_MAIL_ENABLED=false
+RISK_ALERT_MAIL_ENABLED=true
+RISK_ALERT_MAIL_DRY_RUN=false
+RISK_ALERT_MAIL_RECIPIENTS=你的邮箱账号
+RISK_DAILY_REPORT_MAIL_ENABLED=true
RISK_DAILY_REPORT_MAIL_DRY_RUN=true
-RISK_SMTP_HOST=
+RISK_SMTP_HOST=smtp.163.com
RISK_SMTP_PORT=465
-RISK_SMTP_USERNAME=
-RISK_SMTP_PASSWORD=
-RISK_SMTP_SENDER=
+RISK_SMTP_USERNAME=你的邮箱账号
+RISK_SMTP_PASSWORD=你的SMTP授权码
+RISK_SMTP_SENDER=你的邮箱账号
RISK_SMTP_USE_SSL=true
RISK_SMTP_TIMEOUT_SECONDS=30
diff --git a/app/api/controllers/risk.py b/app/api/controllers/risk.py
index d9f380e..b9637f0 100644
--- a/app/api/controllers/risk.py
+++ b/app/api/controllers/risk.py
@@ -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
diff --git a/app/core/config.py b/app/core/config.py
index e0b3e06..f80514f 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -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
diff --git a/app/service/risk_daily_report_mail_service.py b/app/service/risk_daily_report_mail_service.py
index 3bff8a0..024316d 100644
--- a/app/service/risk_daily_report_mail_service.py
+++ b/app/service/risk_daily_report_mail_service.py
@@ -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:
diff --git a/app/service/risk_notification_service.py b/app/service/risk_notification_service.py
index 9cbddf3..c3eba08 100644
--- a/app/service/risk_notification_service.py
+++ b/app/service/risk_notification_service.py
@@ -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],
diff --git a/app/service/risk_scan_service.py b/app/service/risk_scan_service.py
index 9a203da..555b2c7 100644
--- a/app/service/risk_scan_service.py
+++ b/app/service/risk_scan_service.py
@@ -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()
+ ]
diff --git a/app/service/risk_smtp_mail_service.py b/app/service/risk_smtp_mail_service.py
new file mode 100644
index 0000000..3dbb13a
--- /dev/null
+++ b/app/service/risk_smtp_mail_service.py
@@ -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)
diff --git a/app/worker/risk_scan_scheduler.py b/app/worker/risk_scan_scheduler.py
index 9c5a4f1..4cd9e07 100644
--- a/app/worker/risk_scan_scheduler.py
+++ b/app/worker/risk_scan_scheduler.py
@@ -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:
diff --git a/tests/unit/api/test_risk_controller.py b/tests/unit/api/test_risk_controller.py
index 995789e..840294c 100644
--- a/tests/unit/api/test_risk_controller.py
+++ b/tests/unit/api/test_risk_controller.py
@@ -56,6 +56,10 @@ class StubRiskScanService:
def __init__(self, _session: Any) -> None:
pass
+ @classmethod
+ def from_settings(cls, session: Any) -> "StubRiskScanService":
+ return cls(session)
+
async def scan(self, _context: RequestContext) -> dict[str, Any]:
return {"message": "规则扫描完成", "created_count": 2, "high_risk_count": 1}
diff --git a/tests/unit/service/test_risk_scan_alert_mail.py b/tests/unit/service/test_risk_scan_alert_mail.py
new file mode 100644
index 0000000..15d7db1
--- /dev/null
+++ b/tests/unit/service/test_risk_scan_alert_mail.py
@@ -0,0 +1,125 @@
+"""验证高风险预警扫描会真实调用 SMTP,并正确回写通知状态。"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from typing import Any, cast
+
+import pytest
+
+from app.service.risk_scan_service import HIGH_RISK, RiskScanService
+from app.service.risk_smtp_mail_service import RiskSmtpSendError
+
+
+class FakeNested:
+ async def __aenter__(self) -> FakeNested:
+ return self
+
+ async def __aexit__(self, *_args: object) -> bool:
+ return False
+
+
+class FakeSession:
+ def begin_nested(self) -> FakeNested:
+ return FakeNested()
+
+
+class FakeNotificationService:
+ def __init__(self) -> None:
+ self.mail_records: list[Any] = []
+ self.mail_marks: list[tuple[str, str | None]] = []
+
+ def create_in_app(self, _alert: Any, **_kwargs: Any) -> None:
+ return None
+
+ def create_mail_record(self, alert: Any, **_kwargs: Any) -> Any:
+ notification = SimpleNamespace(
+ alert=alert,
+ send_status="待发送",
+ sent_at=None,
+ fail_reason=None,
+ )
+ self.mail_records.append(notification)
+ return notification
+
+ def mark_mail_sent(self, notification: Any) -> None:
+ self.mail_marks.append(("sent", None))
+ notification.send_status = "已发送"
+
+ def mark_mail_failed(self, notification: Any, reason: str) -> None:
+ self.mail_marks.append(("failed", reason))
+ notification.send_status = "发送失败"
+
+
+def alert() -> Any:
+ return SimpleNamespace(
+ alert_level=HIGH_RISK,
+ alert_no="ALTEST",
+ alert_type="大额快进快出",
+ evidence_summary="3 日内入金后大额赎回",
+ handler_id=9002,
+ )
+
+
+def service() -> RiskScanService:
+ instance = object.__new__(RiskScanService)
+ instance.session = cast(Any, FakeSession())
+ instance.notification_enabled = True
+ instance.notification_email = "risk@example.com"
+ instance.mail_enabled = True
+ instance.mail_dry_run = False
+ instance.notification_service = cast(Any, FakeNotificationService())
+ return instance
+
+
+@pytest.mark.asyncio
+async def test_high_risk_mail_is_sent_and_marked_successful() -> None:
+ class SmtpStub:
+ def __init__(self) -> None:
+ self.calls: list[tuple[list[str], str, str]] = []
+
+ async def send(
+ self,
+ recipients: list[str],
+ subject: str,
+ content: str,
+ ) -> dict[str, object]:
+ self.calls.append((recipients, subject, content))
+ return {"status": "sent", "recipient_count": len(recipients)}
+
+ instance = service()
+ notifier = cast(FakeNotificationService, instance.notification_service)
+ smtp = SmtpStub()
+ instance.smtp_mail_service = cast(Any, smtp)
+
+ count, failure = await instance._create_notifications([alert()])
+
+ assert count == 2
+ assert failure == ""
+ assert smtp.calls[0][0] == ["risk@example.com"]
+ assert "预警编号:ALTEST" in smtp.calls[0][2]
+ assert notifier.mail_marks == [("sent", None)]
+ assert notifier.mail_records[0].send_status == "已发送"
+
+
+@pytest.mark.asyncio
+async def test_high_risk_mail_failure_does_not_abort_scan() -> None:
+ class FailingSmtp:
+ async def send(
+ self,
+ _recipients: list[str],
+ _subject: str,
+ _content: str,
+ ) -> dict[str, object]:
+ raise RiskSmtpSendError("SMTP 身份验证失败")
+
+ instance = service()
+ notifier = cast(FakeNotificationService, instance.notification_service)
+ instance.smtp_mail_service = cast(Any, FailingSmtp())
+
+ count, failure = await instance._create_notifications([alert()])
+
+ assert count == 2
+ assert failure == ""
+ assert notifier.mail_marks == [("failed", "SMTP 身份验证失败")]
+ assert notifier.mail_records[0].send_status == "发送失败"
diff --git a/tests/unit/service/test_risk_scan_notification.py b/tests/unit/service/test_risk_scan_notification.py
index ec3a1fe..3bcb32b 100644
--- a/tests/unit/service/test_risk_scan_notification.py
+++ b/tests/unit/service/test_risk_scan_notification.py
@@ -38,6 +38,8 @@ class _FakeNotificationService:
def __init__(self, *, fail: bool = False) -> None:
self.fail = fail
self.in_app: list[str] = []
+ self.mail_records: list[Any] = []
+ self.mail_marks: list[tuple[str, str | None]] = []
def create_in_app(self, alert: Any, **kwargs: Any) -> None:
if self.fail:
@@ -47,6 +49,22 @@ class _FakeNotificationService:
def create_mail_record(self, alert: Any, **kwargs: Any) -> None:
if self.fail:
raise RuntimeError("邮件记录写库失败")
+ notification = SimpleNamespace(
+ alert=alert,
+ send_status="待发送",
+ sent_at=None,
+ fail_reason=None,
+ )
+ self.mail_records.append(notification)
+ return notification
+
+ def mark_mail_sent(self, notification: Any) -> None:
+ self.mail_marks.append(("sent", None))
+ notification.send_status = "已发送"
+
+ def mark_mail_failed(self, notification: Any, reason: str) -> None:
+ self.mail_marks.append(("failed", reason))
+ notification.send_status = "发送失败"
def _alert(level: str, alert_no: str = "ALTEST") -> Any:
diff --git a/tests/unit/service/test_risk_smtp_mail_service.py b/tests/unit/service/test_risk_smtp_mail_service.py
new file mode 100644
index 0000000..e2d0145
--- /dev/null
+++ b/tests/unit/service/test_risk_smtp_mail_service.py
@@ -0,0 +1,71 @@
+"""验证风控共用 SMTP 服务的基础发送行为。"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from app.service import risk_smtp_mail_service
+from app.service.risk_smtp_mail_service import (
+ RiskSmtpConfigurationError,
+ RiskSmtpMailService,
+)
+
+
+class SmtpStub:
+ instances: list[SmtpStub] = []
+
+ def __init__(self, host: str, port: int, timeout: float) -> None:
+ self.host = host
+ self.port = port
+ self.timeout = timeout
+ self.login_args: tuple[str, str] | None = None
+ self.message: Any = None
+ self.__class__.instances.append(self)
+
+ def __enter__(self) -> SmtpStub:
+ return self
+
+ def __exit__(self, *_args: object) -> None:
+ return None
+
+ def login(self, username: str, password: str) -> None:
+ self.login_args = (username, password)
+
+ def send_message(self, message: Any) -> None:
+ self.message = message
+
+
+@pytest.mark.asyncio
+async def test_smtp_service_sends_message(monkeypatch: pytest.MonkeyPatch) -> None:
+ SmtpStub.instances = []
+ monkeypatch.setattr(risk_smtp_mail_service.smtplib, "SMTP_SSL", SmtpStub)
+
+ result = await RiskSmtpMailService(
+ environment={
+ "RISK_SMTP_HOST": "smtp.example.com",
+ "RISK_SMTP_PORT": "465",
+ "RISK_SMTP_USERNAME": "sender@example.com",
+ "RISK_SMTP_PASSWORD": "secret",
+ "RISK_SMTP_SENDER": "sender@example.com",
+ "RISK_SMTP_USE_SSL": "true",
+ }
+ ).send(["risk@example.com"], "高风险预警", "请复核")
+
+ assert result == {"status": "sent", "recipient_count": 1}
+ client = SmtpStub.instances[0]
+ assert (client.host, client.port) == ("smtp.example.com", 465)
+ assert client.login_args == ("sender@example.com", "secret")
+ assert client.message["To"] == "risk@example.com"
+ assert client.message["Subject"] == "高风险预警"
+
+
+@pytest.mark.asyncio
+async def test_smtp_service_rejects_missing_configuration() -> None:
+ with pytest.raises(RiskSmtpConfigurationError):
+ await RiskSmtpMailService(environment={}).send(
+ ["risk@example.com"],
+ "高风险预警",
+ "请复核",
+ )
From 76923d7e8b69c9950fb3f7bef528aea89d94df21 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com>
Date: Sat, 12 Sep 2026 16:08:38 +0800
Subject: [PATCH 2/9] =?UTF-8?q?fix(portal):=20=E8=A1=A5=E9=BD=90=E5=86=99?=
=?UTF-8?q?=E6=93=8D=E4=BD=9C=E7=9A=84=E5=BF=85=E5=A1=AB=20body=EF=BC=88?=
=?UTF-8?q?=E9=A3=8E=E6=8E=A7=E5=8D=87=E7=BA=A7/=E8=A7=A3=E5=86=B3?=
=?UTF-8?q?=E3=80=81=E5=9C=BA=E5=A4=96=E5=85=AD=E4=B8=AA=E5=86=99=E6=8E=A5?=
=?UTF-8?q?=E5=8F=A3=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 风控:escalations 要 reason、resolutions 要 resolution(字段名不同),
且真实顺序是 先确认接收 才能升级/解决(否则 409 请先确认接收预警)
- 场外:六个写接口都必填 operator_id(防伪校验,须等于当前登录用户),
confirmations 还要 decision(中文枚举)、notifications 还要 notification_type、
recalculate 要 fund_code+application_date;门户自动带当前 user_id
- 实测:recalculate 200 code=0;不传 operator_id 必得 422(证明该字段必须)
- 清单 §3/§4 更新为实测结果并列出各写接口的必填字段表
---
docs/40-前端验收清单.md | 31 ++++++++++++-----
tools/portal.py | 75 +++++++++++++++++++++++++++++++----------
2 files changed, 80 insertions(+), 26 deletions(-)
diff --git a/docs/40-前端验收清单.md b/docs/40-前端验收清单.md
index 9bf8c75..3aad0c0 100644
--- a/docs/40-前端验收清单.md
+++ b/docs/40-前端验收清单.md
@@ -79,8 +79,8 @@ D:\conda\envs\jr_py313\python.exe tools\portal.py --base-url http://127.0.0.1:80
| 3-3 | 进入即自动加载"预警列表" | **表格出现**:预警号 / 客户 / 等级 / 规则 / 状态 / 操作。本机实测 2 条:`ALDEMO0002`(高,RW-015/RW-003)、`ALDEMO0001`(中,RW-007/RW-002/RW-012),均"待处理" ✅实测
⚠️ **门户刻意不传 `limit`**:该接口 `limit` 上限是 **5**,传 20 会得到 `422 query.limit: Input should be less than or equal to 5`,**整张表格渲染不出来**(行内按钮也随之消失) |
| 3-4 | 点「触发一次扫描」 | **HTTP 200,`body.code=0`**(这条以前会因缺幂等头报 422,已修) ✅实测 |
| 3-5 | 点「生成日报」 | HTTP 200,返回日报内容 ⚠️按契约 |
-| 3-6 | 对某条预警点「确认」 | 二次确认后返回业务结果。✅实测:`POST .../acknowledgements` → **409「只有待处理的预警才能确认解决」** —— 该动作**有状态前置条件**,不是任意状态都能点,属正常业务约束(页面会原样显示原因) |
-| 3-7 | 点「升级」/「解决」 | 同上,各自有状态约束;状态不符时返回 409 ⚠️按契约 |
+| 3-6 | 对某条预警点「确认」 | 二次确认后返回业务结果。✅实测:`POST .../acknowledgements`(**无必填 body**)→ **409「只有待处理的预警才能确认解决」** —— 该动作**有状态前置条件**,不是任意状态都能点 |
+| 3-7 | 点「升级」/「解决」 | ✅实测:**必须先「确认」接收预警**,否则两者都返回 **409「请先确认接收预警」**。
升级要填 reason、解决要填 resolution(**字段名不同**,各 1-500 字),门户已做成弹窗必填;不填会是 422 |
| 3-8 | 点一个**不存在**的预警号 | 404 资源不存在 —— 正常 fail closed ⚠️按契约 |
| 3-9 | 用**客户**账号访问风控接口 | **403 缺少操作权限**(`risk_t` 能看,`cust_t` 不能) ✅实测(客户访问 `/admin/roles` 为 403) |
@@ -88,7 +88,8 @@ D:\conda\envs\jr_py313\python.exe tools\portal.py --base-url http://127.0.0.1:80
> 这是种子设计如此,不是越权漏洞。
>
> ⚠️ 行内三条按钮对应 `acknowledgements` / `escalations` / `resolutions` 三个端点,都是**写操作**:
-> 会在库里留数据与审计,且**受状态机约束**。列表拿不到数据时这三个按钮不会出现 ——
+> 会在库里留数据与审计,且**受状态机约束**(**确认 → 升级/解决**)。三个动作的请求体各不相同:
+> 确认无 body、升级要 `reason`、解决要 `resolution`。列表拿不到数据时这三个按钮不会出现 ——
> 先确认 3-3 是否正常。
---
@@ -98,14 +99,26 @@ D:\conda\envs\jr_py313\python.exe tools\portal.py --base-url http://127.0.0.1:80
| # | 操作 | 预期结果 |
|---|---|---|
| 4-1 | 进入即加载"邮箱状态" | 数字卡片,HTTP 200 ✅实测 |
-| 4-2 | 点「拉取邮件列表」 | 表格(邮件 ID / 主题 / 状态 / 操作);**邮箱未配置时**返回空列表或错误说明,不是白屏 ✅实测(HTTP 200) |
+| 4-2 | 点「拉取邮件列表」 | 表格(邮件 ID / 主题 / 状态 / 操作)。**邮箱未配置时条数为 0**、不白屏 ✅实测(HTTP 200,0 条) |
| 4-3 | 点某封邮件的「识别字段」 | 返回识别结果 JSON ⚠️按契约(需库里有邮件数据) |
-| 4-4 | 点「删除」邮件 | 二次确认后写操作;审计留痕 ⚠️按契约 |
-| 4-5 | 点「触发邮箱恢复」 | 二次确认后 HTTP 200 ⚠️按契约 |
+| 4-4 | 点「删除」邮件 | 二次确认后写操作。门户自动带 `operator_id`(= 当前登录 user_id)✅实测(字段齐了才会进业务层) |
+| 4-5 | 点「触发邮箱恢复」 | ✅实测:`HTTP 200 + body.code=404「邮箱尚未初始化」` —— 请求体已合法,是本机没配邮箱,属正常 |
| 4-6 | 在"单据处理"填一个**真实存在**的 task_id,点「识别字段」「规则结果」 | 返回该单据的字段与规则判定 ⚠️按契约 |
-| 4-7 | 填一个**不存在**的 task_id | 404 资源不存在,页面上原样显示 —— 正常 ⚠️按契约 |
-| 4-8 | 点「确认单据」/「重试识别」/「创建通知」 | 二次确认后写操作;审计留痕 ⚠️按契约 |
-| 4-9 | 点「重算结算统计」 | 二次确认后 HTTP 200 ⚠️按契约 |
+| 4-7 | 填一个**不存在**的 task_id | 422/404,页面上原样显示 —— 正常 ⚠️实测(假 task_id 得到 422) |
+| 4-8 | 点「确认单据」/「重试识别」/「创建通知」 | 见下方"三个写操作的必填字段",各自会弹窗要必填值 ⚠️按契约 |
+| 4-9 | 点「重算结算统计」 | ✅实测:要填 `fund_code` + `application_date`(门户已弹窗),**HTTP 200 `code=0` ok** |
+
+> **场外写操作的三条硬约束**(实测得出,门户已按此实现):
+>
+> | 接口 | 必填字段 |
+> |---|---|
+> | `mailbox-status/recoveries`、`mails/{id}/deletions`、`documents/{id}/recognition-retries` | `operator_id` |
+> | `documents/{id}/confirmations` | `decision`(**中文枚举**:确认无误 / 确认异常 / 未处理)+ `operator_id` |
+> | `documents/{id}/notifications` | `notification_type`(risk / settlement / mail_return / normal_return / exception_return…)+ `operator_id` |
+> | `settlement-statistics/recalculate` | `fund_code` + `application_date`(**没有** operator_id) |
+>
+> `operator_id` 是**防伪校验**:平台会核对它是否等于当前登录用户(传别人的会被拒)。
+> 实测不传它必得 **422**,所以门户一律自动带本次登录的 user_id,不让你手填。
> **运营为什么只有 2 项权限却能用**:场外线的服务层用的是**角色门槛**
> `{"operator","risk_operator","admin","super_admin"}`(`offsite_fund_service.py:2600`),
diff --git a/tools/portal.py b/tools/portal.py
index 0d0e689..cd5ee8e 100644
--- a/tools/portal.py
+++ b/tools/portal.py
@@ -724,7 +724,9 @@ function renderStaff(box) {
预警列表
-
点「处置」可执行确认 / 升级 / 解决 —— 这些是真实写操作,会在审计里留痕。
+
操作有业务顺序:必须先点「确认」接收预警,才能「升级」或「解决」——
+ 顺序不对会返回 409 请先确认接收预警。
+ 这三个都是真实写操作,会在审计留痕;升级要填原因、解决要填处理结论(各 1-500 字)。
`;
@@ -786,14 +788,26 @@ async function dailyReport() {
showExtra2('日报结果', r);
}
-async function ack(no) { await act_(no, 'acknowledgements', '确认'); }
-async function esc_(no) { await act_(no, 'escalations', '升级'); }
-async function resolve(no) { await act_(no, 'resolutions', '解决'); }
+async function ack(no) { await act_(no, 'acknowledgements', '确认'); }
-async function act_(no, action, label) {
+async function esc_(no) { // 升级:接口要求 reason(1-500 字)
+ const reason = prompt('升级原因(必填,最多 500 字):', '客户风险等级需人工复核');
+ if (reason === null) return;
+ if (!reason.trim()) return alert('升级原因不能为空');
+ await act_(no, 'escalations', '升级', { reason: reason.slice(0, 500) });
+}
+
+async function resolve(no) { // 解决:字段名是 resolution,不是 reason
+ const resolution = prompt('处理结论(必填,最多 500 字):', '已联系客户核实,风险已排除');
+ if (resolution === null) return;
+ if (!resolution.trim()) return alert('处理结论不能为空');
+ await act_(no, 'resolutions', '解决', { resolution: resolution.slice(0, 500) });
+}
+
+async function act_(no, action, label, body) {
if (!confirm(`对预警 ${no} 执行「${label}」?这会写审计。`)) return;
const r = await jpost('/api/call', { method:'POST',
- path: `/api/v1/risk/alerts/${no}/${action}`, body: {} });
+ path: `/api/v1/risk/alerts/${no}/${action}`, body: body || {} });
showExtra2(`${label} ${no}`, r);
loadAlerts();
}
@@ -810,8 +824,9 @@ function renderOffsite(box) {
运营工作台 · 场外基金
面向 operator。场外线的服务层用**角色门槛**
- {"operator","risk_operator","admin","super_admin"} 判断,所以主体功能靠角色就通;
- 另外给了 financial:nl2sql:read,用于单据字段识别。
+
{"operator","risk_operator","admin","super_admin"} 判断,所以主体功能靠角色就通。
+ 另外:场外的**写接口必须带
operator_id**,而且是**防伪校验** —— 平台会核对
+ 它是否等于当前登录用户,所以门户一律自动带本次登录的 user_id,不让你手填。
@@ -868,19 +883,25 @@ async function loadMails() {
: `
HTTP ${r.status}${r.status === 403 ? ' —— 权限不足' : ''}
${esc(pretty(r.body))}`;
}
+// 场外线的写接口**必须带 operator_id**,而且是防伪校验:平台会核对它是否等于当前
+// 登录用户。所以这里一律取本次登录的 user_id,不硬编码、也不让用户随便填。
+function myId() { return (ME && ME.user_id) || ''; }
+
async function mailFields(id) {
showOffsite('邮件识别字段 ' + id, await GET(`/api/v1/offsite-fund/mails/${id}/recognition-fields`));
}
async function mailDelete(id) {
if (!confirm('删除邮件 ' + id + '?这是写操作。')) return;
showOffsite('删除邮件 ' + id, await jpost('/api/call',
- { method:'POST', path:`/api/v1/offsite-fund/mails/${id}/deletions`, body:{} }));
+ { method:'POST', path:`/api/v1/offsite-fund/mails/${id}/deletions`,
+ body:{ operator_id: myId() } }));
loadMails();
}
async function recoverMailbox() {
if (!confirm('触发邮箱恢复?这是写操作。')) return;
showOffsite('邮箱恢复', await jpost('/api/call',
- { method:'POST', path:'/api/v1/offsite-fund/mailbox-status/recoveries', body:{} }));
+ { method:'POST', path:'/api/v1/offsite-fund/mailbox-status/recoveries',
+ body:{ operator_id: myId() } }));
}
async function docFields() {
const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
@@ -892,25 +913,45 @@ async function docRules() {
}
async function docConfirm() {
const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
- if (!confirm('确认单据 ' + t + '?这是写操作,会进审计。')) return;
+ // decision 是**中文枚举**:确认无误 / 确认异常 / 未处理
+ const decision = prompt('确认结论(确认无误 / 确认异常 / 未处理):', '确认无误');
+ if (decision === null) return;
+ if (['确认无误', '确认异常', '未处理'].indexOf(decision) < 0) {
+ return alert('只能是:确认无误 / 确认异常 / 未处理');
+ }
+ if (!confirm(`对单据 ${t} 提交「${decision}」?这是写操作,会进审计。`)) return;
showOffsite('确认单据 ' + t, await jpost('/api/call',
- { method:'POST', path:`/api/v1/offsite-fund/documents/${t}/confirmations`, body:{} }));
+ { method:'POST', path:`/api/v1/offsite-fund/documents/${t}/confirmations`,
+ body:{ decision, operator_id: myId() } }));
}
async function docRetry() {
const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
showOffsite('重试识别 ' + t, await jpost('/api/call',
- { method:'POST', path:`/api/v1/offsite-fund/documents/${t}/recognition-retries`, body:{} }));
+ { method:'POST', path:`/api/v1/offsite-fund/documents/${t}/recognition-retries`,
+ body:{ operator_id: myId() } }));
}
async function docNotify() {
const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
- if (!confirm('为单据 ' + t + ' 创建通知?')) return;
+ // notification_type 取值:risk / settlement / mail_return / normal_return / exception_return…
+ const type = prompt('通知类型(risk / settlement / mail_return / normal_return / exception_return):',
+ 'normal_return');
+ if (type === null) return;
+ if (!confirm(`为单据 ${t} 创建「${type}」通知?`)) return;
showOffsite('创建通知 ' + t, await jpost('/api/call',
- { method:'POST', path:`/api/v1/offsite-fund/documents/${t}/notifications`, body:{} }));
+ { method:'POST', path:`/api/v1/offsite-fund/documents/${t}/notifications`,
+ body:{ notification_type: type, operator_id: myId() } }));
}
async function settle() {
- if (!confirm('重算结算统计?这是写操作。')) return;
+ // 这个接口要的是 fund_code + application_date,没有 operator_id
+ const fund = prompt('基金代码 fund_code:', '');
+ if (fund === null) return;
+ const date = prompt('申请日期 application_date(YYYY-MM-DD):', '');
+ if (date === null) return;
+ if (!fund.trim() || !date.trim()) return alert('基金代码与申请日期都必填');
+ if (!confirm(`重算 ${fund} 在 ${date} 的结算统计?这是写操作。`)) return;
showOffsite('结算重算', await jpost('/api/call',
- { method:'POST', path:'/api/v1/offsite-fund/settlement-statistics/recalculate', body:{} }));
+ { method:'POST', path:'/api/v1/offsite-fund/settlement-statistics/recalculate',
+ body:{ fund_code: fund.trim(), application_date: date.trim() } }));
}
function showOffsite(title, r) {
$('offsite-extra').innerHTML = `
${esc(title)}
From a03d5e33c3f51bf285f2a682141704e9c92a86a0 Mon Sep 17 00:00:00 2001
From: zhangshy <994452054@qq.com>
Date: Sat, 12 Sep 2026 16:30:01 +0800
Subject: [PATCH 3/9] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=A3=8E=E6=8E=A7?=
=?UTF-8?q?=E9=82=AE=E4=BB=B6=E9=80=9A=E7=9F=A5=E4=B8=8E=E5=AE=9A=E6=97=B6?=
=?UTF-8?q?=E6=89=AB=E6=8F=8F=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/21-风控业务第二版迁移清单.md | 7 ++--
.../风控业务演示文档/06-模块接口与字段映射.md | 10 ++++++
.../风控业务演示文档/12-日报通知与邮件规则.md | 29 ++++++++++++++++-
.../风控业务演示文档/15-模块验收与演示清单.md | 5 ++-
docs/风控业务演示文档/16-已知限制与待办.md | 10 +++++-
.../17-前端合并提示词与验收约束.md | 32 +++++++++++++++++--
docs/风控业务演示文档/18-当前项目完成进度.md | 25 ++++++++-------
.../风控业务演示文档/19-风控模块配置项清单.md | 16 ++++++++--
.../21-主项目合并后后端必改清单.md | 21 ++++++++++--
9 files changed, 131 insertions(+), 24 deletions(-)
diff --git a/docs/21-风控业务第二版迁移清单.md b/docs/21-风控业务第二版迁移清单.md
index bd6803b..7619b12 100644
--- a/docs/21-风控业务第二版迁移清单.md
+++ b/docs/21-风控业务第二版迁移清单.md
@@ -539,12 +539,14 @@ tests/unit/api/test_risk_controller.py
已实现:
- 站内通知记录创建。
-- 邮件通知记录创建,本阶段不外发 SMTP。
+- 邮件通知记录创建;迁移阶段当时尚未接入 SMTP。
- 通知主键和通知编号显式生成。
- 通知内容自动包含“预警编号:xxx”。
- 高风险批量通知记录。
- 通知分页复用 `RiskQueryService` 和 `RiskRepository`。
+> 2026-09-12 更新:本节“本阶段不外发 SMTP”只描述 R6.1 当时状态。当前手动扫描和定时扫描命中高风险后已恢复真实邮件发送,通知状态会回写 `已发送` 或 `发送失败`。当前高风险预警邮件只支持一个收件人。
+
验证结果:当前全部风控专项测试共 `58 passed`。
下一步:R6.2 将高风险通知创建接入扫描事务,并验证通知失败不破坏预警主事务。等待确认后执行。
@@ -556,7 +558,8 @@ tests/unit/api/test_risk_controller.py
已实现:
- 扫描生成高风险预警后自动创建站内通知记录。
-- 可选邮件通知记录,默认不发送 SMTP。
+- 高风险邮件通知记录和真实 SMTP 外发,发送失败不回滚预警。
+- 高风险预警邮件当前只支持一个收件人,多个邮箱值只取第一个。
- 通知创建使用嵌套保存点隔离。
- 通知创建失败只记录日志,不回滚预警扫描主事务。
- 扫描结果增加 `notification_count`。
diff --git a/docs/风控业务演示文档/06-模块接口与字段映射.md b/docs/风控业务演示文档/06-模块接口与字段映射.md
index 53d22a2..d63a104 100644
--- a/docs/风控业务演示文档/06-模块接口与字段映射.md
+++ b/docs/风控业务演示文档/06-模块接口与字段映射.md
@@ -79,6 +79,16 @@
同一用户、同一路径、同一键的重复请求直接回放首次响应;同一键换了请求正文返回
`409 IDEMPOTENCY_CONFLICT`。证据上传靠"同一预警只能归档一次"的冲突保护去重。
+## 扫描通知与高风险邮件
+
+- `POST /alerts/scan` 同时用于手动扫描,定时扫描复用同一 `RiskScanService`。
+- 高风险预警会创建站内通知和邮件通知。
+- 邮件发送成功时通知状态为 `已发送`;发送失败时为 `发送失败` 并返回失败原因。
+- 邮件发送失败不会改变扫描接口的成功状态,也不会回滚预警和站内通知。
+- `notification_count` 统计站内通知和邮件通知记录总数。
+- `notification_failure` 只表示通知记录创建失败,不表示 SMTP 邮件发送失败。
+- `RISK_ALERT_MAIL_RECIPIENTS` 当前只支持一个收件人,多个邮箱只取第一个。
+
## 日报和邮件
| 方法 | 路径 | 功能 | 权限 |
diff --git a/docs/风控业务演示文档/12-日报通知与邮件规则.md b/docs/风控业务演示文档/12-日报通知与邮件规则.md
index 8189d5f..116a3d9 100644
--- a/docs/风控业务演示文档/12-日报通知与邮件规则.md
+++ b/docs/风控业务演示文档/12-日报通知与邮件规则.md
@@ -48,7 +48,33 @@
- 数据库保留 `read_at`、`acknowledged_at` 基线字段,但当前未实现写入逻辑。
- 通知接口和前端不返回或展示阅读时间、确认时间。
-## 邮件开关
+### 高风险预警邮件
+
+- 手动扫描和定时扫描共用同一套高风险预警邮件发送逻辑。
+- 高风险预警生成后,后端创建站内通知和邮件通知,并尝试调用真实 SMTP。
+- 邮件发送成功时,通知状态更新为 `已发送` 并写入发送时间。
+- 邮件发送失败时,通知状态更新为 `发送失败` 并写入安全失败原因。
+- 邮件发送失败不回滚预警,也不回滚站内通知。
+- 当前 `RISK_ALERT_MAIL_RECIPIENTS` 只支持一个收件人。即使配置多个邮箱,也只有第一个邮箱生效。
+- 高风险预警邮件收件人由后端配置,前端不提供收件人输入框。
+
+高风险预警邮件配置:
+
+- `RISK_ALERT_MAIL_ENABLED`
+- `RISK_ALERT_MAIL_DRY_RUN`
+- `RISK_ALERT_MAIL_RECIPIENTS`
+
+高风险预警邮件复用以下 SMTP 配置:
+
+- `RISK_SMTP_HOST`
+- `RISK_SMTP_PORT`
+- `RISK_SMTP_USERNAME`
+- `RISK_SMTP_PASSWORD`
+- `RISK_SMTP_SENDER`
+- `RISK_SMTP_USE_SSL`
+- `RISK_SMTP_TIMEOUT_SECONDS`
+
+## 日报邮件开关
发送日报邮件需要 `risk:report:mail` 权限。
@@ -72,6 +98,7 @@
## 收件人
+- 本节多个收件人规则只适用于日报邮件;高风险预警邮件当前只支持一个收件人。
- 支持多个收件人。
- 收件人去重并校验格式。
- 单次最多 10 个收件人。
diff --git a/docs/风控业务演示文档/15-模块验收与演示清单.md b/docs/风控业务演示文档/15-模块验收与演示清单.md
index 641e0d4..93af2d0 100644
--- a/docs/风控业务演示文档/15-模块验收与演示清单.md
+++ b/docs/风控业务演示文档/15-模块验收与演示清单.md
@@ -40,6 +40,8 @@
- 定时扫描默认关闭,多个 Worker 同时运行时不重复执行。
- 定时扫描成功和失败写入系统审计。
- 扫描返回 `notification_failure` 时,页面必须区分成功与通知失败。
+- 手动扫描和定时扫描命中高风险后,高风险预警邮件按后端配置自动发送;当前只支持一个收件人。
+- 高风险邮件发送成功后通知状态为 `已发送`,失败时为 `发送失败`,且不影响预警落库。
- 重复扫描不重复生成同一交易和规则的预警。
- 多规则命中时正确合并。
@@ -69,6 +71,7 @@
- `data_truncated=true` 时不得把计数当作全量。
- 误报、处置结果和规则效果正确统计。
- 邮件接口要求 `risk:report:mail`,开关关闭时不发送真实邮件。
+- 日报邮件支持多个收件人;高风险预警邮件只支持一个收件人,两类邮件互不替代。
## Agent 验收
@@ -88,7 +91,7 @@
5. 展示行为分变化。
6. 使用奶龙风控智能助手查询和研判。
7. 生成并查看日报。
-8. 查看通知或邮件 dry-run 结果。
+8. 查看高风险预警邮件状态,并单独演示日报邮件 dry-run 或真实发送。
## 通过标准
diff --git a/docs/风控业务演示文档/16-已知限制与待办.md b/docs/风控业务演示文档/16-已知限制与待办.md
index e7f03c1..b3c2225 100644
--- a/docs/风控业务演示文档/16-已知限制与待办.md
+++ b/docs/风控业务演示文档/16-已知限制与待办.md
@@ -27,7 +27,14 @@
- Redis 不可用时,缓存和限流可能降级,不影响结构化业务主流程。
- Milvus 不可用时,语义记忆关闭,结构化查询继续运行。
- 模型服务不可用时,使用模板或规则化降级。
-- SMTP 未开启时,邮件接口返回禁用或 dry-run。
+- 日报邮件在 SMTP 未开启或 dry-run 时不会发送真实邮件。
+- 高风险预警邮件在 SMTP 发送失败时只更新通知状态,不回滚预警。
+
+## 邮件通知限制
+
+- `RISK_ALERT_MAIL_RECIPIENTS` 当前只支持一个收件人,多个邮箱配置只取第一个。
+- 高风险预警邮件和日报邮件是两条独立链路,收件人配置和开关互不替代。
+- 正式使用不能要求业务人员手工启动 `python -m app.worker.risk_scan_scheduler`。
## 前端范围
@@ -52,6 +59,7 @@
- 主项目合并完成后对齐统一 RBAC 和客户数据范围。
- 修复公共 `data_scope` 最高权限跨资源扩散问题。
+- 将风控定时扫描接入主项目统一 Worker 或统一部署编排,并补充一个收件人以外的多收件人能力评估。
- 确认幂等回执与业务 Action 内部提交的事务边界。
- 私有前端最后再适配 `data/meta` 列表信封和写接口 `Idempotency-Key`。
- 根据合规要求确定会话保留期、脱敏和归档策略。
diff --git a/docs/风控业务演示文档/17-前端合并提示词与验收约束.md b/docs/风控业务演示文档/17-前端合并提示词与验收约束.md
index 744bede..ca20e72 100644
--- a/docs/风控业务演示文档/17-前端合并提示词与验收约束.md
+++ b/docs/风控业务演示文档/17-前端合并提示词与验收约束.md
@@ -21,11 +21,35 @@
后续模型开始前端合并前,必须先阅读本目录全部文档,并扫描上述代码和路由。
+## 前端合并前必须对齐的补充口径(2026-09-12)
+
+本节优先于后文中的概括性描述。前端合并时以本节为准。
+
+### 风险概览口径
+
+- 风险概览接口的 `total` 表示当前未闭环预警,状态范围为 `待处理` 和 `调查中`。
+- `total` 不是当天新增预警,不受 `created_at` 当天范围限制。
+- 前端指标名称应使用“未闭环预警”,不得继续显示为“今日预警”。
+
+### 高风险预警邮件
+
+- 手动扫描和定时扫描命中高风险预警后,后端都会创建站内通知并尝试发送邮件。
+- 高风险预警邮件由后端配置决定收件人,前端不提供收件人输入框。
+- 高风险预警邮件当前只支持一个收件人,前端不能按“多收件人”设计配置界面。
+- 邮件状态取值包括 `待发送`、`已发送`、`发送失败`、`未启用`。
+- 前端通知记录列表必须展示发送状态和失败原因,不能把“已创建通知记录”显示为“邮件已发送”。
+- 邮件发送失败不回滚预警,也不等于整次扫描失败。扫描成功后仅刷新通知记录即可看到真实邮件状态。
+
+### 两类邮件必须区分
+
+- 高风险预警邮件:手动扫描或定时扫描自动触发,收件人来自风控邮件通知配置。
+- 日报邮件:用户在高风险日报弹窗中填写收件人后手动发送,与预警扫描邮件互不替代。
+
## 二、前端功能范围
| 页面或区域 | 必需功能 |
|---|---|
-| 风险概览 | 总量、风险等级、待处理、超时、重点预警 |
+| 风险概览 | 当前未闭环总量、风险等级、待处理、超时、重点预警;不得把总量显示为“今日预警” |
| 预警队列 | 风险等级排序、筛选、每页 5 条、分页、弹窗详情 |
| 预警详情 | 预警编号、状态、规则、证据、回执、人工处置 |
| 证据区域 | 客户、产品、交易、资金、持仓、登录、预警、通知八类证据 |
@@ -34,7 +58,7 @@
| 证据归档 | 图片和文档上传、归档状态、失败提示 |
| 奶龙风控智能助手 | 对话、SSE 输出、工具调用展示、能力边界和免责声明 |
| 日报 | 弹窗展示、流式生成、内容编辑、多邮箱发送 |
-| 通知 | 通知记录、预警编号、发送状态 |
+| 通知 | 通知记录、预警编号、站内或邮件渠道、发送状态、失败原因 |
| 系统提示 | 政策解读、日报入口和预留模块 |
## 三、排版和交互约束
@@ -112,7 +136,9 @@
| 上传证据 | 证据上传成功 |
| 手动扫描 | 预警扫描完成 |
| 生成日报 | 日报生成完成 |
-| 邮件发送 | 日报发送成功 |
+| 日报邮件发送 | 日报邮件发送成功 |
+
+手动扫描成功只表示预警扫描完成。高风险预警邮件发送失败不会让扫描接口返回失败,前端应刷新通知记录,并通过通知的发送状态和失败原因展示真实结果。
### 失败提示
diff --git a/docs/风控业务演示文档/18-当前项目完成进度.md b/docs/风控业务演示文档/18-当前项目完成进度.md
index 4f8d256..9d16b80 100644
--- a/docs/风控业务演示文档/18-当前项目完成进度.md
+++ b/docs/风控业务演示文档/18-当前项目完成进度.md
@@ -8,10 +8,10 @@
| 项目 | 当前状态 |
|---|---|
-| 统计日期 | 2026-09-11 |
+| 统计日期 | 2026-09-12 |
| 当前分支 | `RM2_develop` |
| 当前合并基线 | `origin/qyqy_develop` 主项目风控修复批次 |
-| 代码状态 | 已完成主项目风控修复合并,全量测试通过 |
+| 代码状态 | 已完成主项目风控修复合并,专项回归 54 项通过 |
| 已推送分支 | `origin/RM2_develop` |
| 已合并分支 | `origin/qyqy_develop` |
| 私有前端 | `private_frontend/`,未提交、未推送 |
@@ -63,11 +63,12 @@
### 证据、通知和日报
- 图片和文档证据归档。
-- 高风险通知记录。
+- 高风险站内通知和邮件通知,邮件成功或失败状态可回查。
- 九段式日报。
- 历史未闭环完整统计。
- 日报流式生成。
-- 多邮箱校验和 dry-run 邮件发送。
+- 日报多邮箱校验和 dry-run 邮件发送。
+- 高风险预警邮件真实 SMTP 外发,当前只支持一个收件人。
### 奶龙风控智能助手
@@ -124,14 +125,15 @@
### 定时规则扫描
-状态:已完成。
+状态:功能已完成,主项目 Worker 接入待办。
- 新增独立 `RiskScanSchedulerWorker`,不在 Web 进程启动后台线程。
-- 扫描开关、周期、是否立即执行和重试次数由 `.env` 环境变量控制。
+- 扫描开关、间隔、轮询频率和重试次数由 `.env` 环境变量控制。
- 使用 MySQL 咨询锁防止多个 Worker 重复执行。
- 手动扫描和定时扫描共用同一个 `RiskScanService`。
- 扫描成功和失败写入系统审计。
- 默认关闭,配置开启后才执行。
+- 当前仍需独立启动 `python -m app.worker.risk_scan_scheduler`;主项目需要改为统一 Worker 注册或统一部署编排。
### 主项目正式前端
@@ -170,11 +172,12 @@
## 下一阶段建议
1. 将 `RM2_develop` 与最新 `qyqy_develop` 保持同步。
-2. 将定时规则扫描的环境变量配置纳入主项目部署配置。
-3. 按 `17-前端合并提示词与验收约束.md` 合并正式前端。
-4. 使用主项目真实登录、账号、角色和客户归属完成联调。
-5. 执行桌面端、移动端、权限、降级和完整业务链路验收。
-6. 主项目稳定后再实施对话历史、Redis 缓存和长期留存。
+2. 将定时规则扫描和高风险预警邮件配置纳入主项目部署配置。
+3. 将定时规则扫描接入主项目统一 Worker 或部署编排,取消业务人员手工启动独立进程。
+4. 按 `17-前端合并提示词与验收约束.md` 合并正式前端。
+5. 使用主项目真实登录、账号、角色和客户归属完成联调。
+6. 执行桌面端、移动端、权限、降级和完整业务链路验收。
+7. 主项目稳定后再实施对话历史、Redis 缓存和长期留存。
## 完成判定
diff --git a/docs/风控业务演示文档/19-风控模块配置项清单.md b/docs/风控业务演示文档/19-风控模块配置项清单.md
index 4a07738..cc0e374 100644
--- a/docs/风控业务演示文档/19-风控模块配置项清单.md
+++ b/docs/风控业务演示文档/19-风控模块配置项清单.md
@@ -20,9 +20,9 @@
|---|---:|---|
| `RISK_SCAN_SCHEDULE_ENABLED` | `false` | 是否开启定时扫描 |
| `RISK_SCAN_INTERVAL_MINUTES` | `5` | 扫描间隔,单位分钟 |
-| `RISK_SCAN_RUN_IMMEDIATELY` | `false` | Worker 启动后是否立即执行 |
+| `RISK_SCAN_RUN_IMMEDIATELY` | `false` | 仅保留兼容字段,当前不控制启动后的首次执行 |
| `RISK_SCAN_RETRY_LIMIT` | `2` | 单轮失败后的重试次数 |
-| `RISK_SCAN_POLL_SECONDS` | `30` | Worker 轮询配置和到期时间的间隔 |
+| `RISK_SCAN_POLL_SECONDS` | `30` | Worker 检查扫描是否到期的频率,不是实际扫描间隔 |
### 证据归档
@@ -31,6 +31,14 @@
| `RISK_EVIDENCE_DIR` | `storage/risk_evidence` | 证据文件归档根目录 |
| `RISK_EVIDENCE_MAX_FILE_SIZE_MB` | `10` | 单个证据文件最大大小,单位 MB |
+### 高风险预警邮件
+
+| 配置项 | 默认值 | 说明 |
+|---|---:|---|
+| `RISK_ALERT_MAIL_ENABLED` | `false` | 是否允许手动扫描和定时扫描发送高风险预警邮件 |
+| `RISK_ALERT_MAIL_DRY_RUN` | `true` | 为真时只记录 dry-run 结果,不连接 SMTP |
+| `RISK_ALERT_MAIL_RECIPIENTS` | 空 | 高风险预警邮件收件人;当前只支持一个,多个值只取第一个 |
+
### 日报邮件
| 配置项 | 默认值 | 说明 |
@@ -87,7 +95,9 @@
- 联调初期保持 `RISK_SCAN_SCHEDULE_ENABLED=false`。
- 确认扫描规则和演示数据后,再按需改为 `true`。
-- 邮件先保持 `RISK_DAILY_REPORT_MAIL_DRY_RUN=true`。
+- 高风险预警邮件先保持 `RISK_ALERT_MAIL_DRY_RUN=true`,确认预警落库和通知记录无误后再关闭 dry-run。
+- 当前高风险预警邮件只支持一个收件人,多邮箱配置只会有第一个生效。
+- 日报邮件先保持 `RISK_DAILY_REPORT_MAIL_DRY_RUN=true`。
- 正式发送前配置 SMTP,并将 `RISK_DAILY_REPORT_MAIL_ENABLED=true`。
- 证据目录应挂载到持久化存储,不能只保存在临时容器目录。
diff --git a/docs/风控业务演示文档/21-主项目合并后后端必改清单.md b/docs/风控业务演示文档/21-主项目合并后后端必改清单.md
index 2b21b50..5b180d4 100644
--- a/docs/风控业务演示文档/21-主项目合并后后端必改清单.md
+++ b/docs/风控业务演示文档/21-主项目合并后后端必改清单.md
@@ -7,7 +7,7 @@
公共底座、公共事务能力、公共数据范围、公共鉴权基座和公共 SSE 协商等问题不纳入本文,
由主项目统一修复和发布。
-## 本轮已完成
+## 已处理事项
### 1. 证据查询时间口径统一
@@ -53,6 +53,23 @@ Agent 不能把截断结果表述成覆盖全部数据,也不能据此给出
`tools/grant_risk_permissions.py` 的文档字符串已改为实际文件名,
避免执行人员按错误路径操作。
+## 主项目仍需完成
+
+### 6. 高风险预警邮件通知接入
+
+- 主项目部署配置需要接入 `RISK_ALERT_MAIL_ENABLED`、`RISK_ALERT_MAIL_DRY_RUN` 和 `RISK_ALERT_MAIL_RECIPIENTS`。
+- 手动扫描和定时扫描必须继续共用同一套高风险邮件发送逻辑。
+- 高风险预警邮件当前只支持一个收件人,多个邮箱配置只有第一个生效。
+- 邮件发送成功写 `已发送`,失败写 `发送失败`;失败不得回滚预警和站内通知。
+- 日报邮件仍由用户在日报弹窗中填写多个收件人,和预警邮件配置相互独立。
+
+### 7. 定时扫描接入主 Worker
+
+- 当前 `python -m app.worker` 不包含风控定时扫描,独立调度进程只适合本地联调。
+- 主项目需要提供通用后台任务注册入口,或在部署编排中统一拉起风控调度进程。
+- 正式使用不能要求业务人员额外手工执行 `python -m app.worker.risk_scan_scheduler`。
+- 无论采用哪种方式,都必须保留扫描互斥锁和幂等去重。
+
## 联调前需要执行的动作
以下内容属于环境准备或数据初始化,不是代码缺陷:
@@ -68,7 +85,7 @@ Agent 不能把截断结果表述成覆盖全部数据,也不能据此给出
3. 执行 `python tools/publish_risk_agent_config.py`,确认奶龙风控智能助手的工具白名单和意图配置已发布。
4. 按主项目发布的迁移流程执行 Alembic 升级,确认 `trigger_rule_codes` 多值索引已生效。
5. 演示前准备足够的客户、交易、资金、持仓、登录和预警数据。
-6. 使用前确认日报邮件开关、SMTP 配置和收件人范围符合演示要求。
+6. 使用前确认高风险预警邮件开关、日报邮件开关、SMTP 配置和收件人范围符合演示要求。
## 当前保留限制
From cb13f9cf4567ccae060e66f6fb5b95b8148919f5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com>
Date: Sat, 12 Sep 2026 16:30:54 +0800
Subject: [PATCH 4/9] =?UTF-8?q?fix(types):=20trade=5Fservice.=5Fnext=5Fid?=
=?UTF-8?q?=20=E7=9A=84=20model=20=E5=8F=82=E6=95=B0=E6=A0=87=E6=B3=A8?=
=?UTF-8?q?=E6=94=B9=E4=B8=BA=20Any=EF=BC=88mypy:=20type=20=E6=97=A0=20id?=
=?UTF-8?q?=20=E5=B1=9E=E6=80=A7=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/repository/risk_repository.py | 10 +++++++++-
app/service/risk_scan_service.py | 5 ++++-
app/service/trade_service.py | 3 ++-
3 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/app/repository/risk_repository.py b/app/repository/risk_repository.py
index 97d3493..0500b94 100644
--- a/app/repository/risk_repository.py
+++ b/app/repository/risk_repository.py
@@ -7,6 +7,7 @@
from __future__ import annotations
+import json
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, date, datetime
@@ -753,7 +754,14 @@ class RiskRepository:
if risk_level:
conditions.append(FundRiskAlert.alert_level == risk_level)
if rule_code:
- conditions.append(FundRiskAlert.trigger_rule_codes.contains([rule_code]))
+ # 必须用 `JSON_CONTAINS`,**不能用 `.contains([rule_code])`**:
+ # SQLAlchemy 会把 `.contains()` 编译成 `LIKE`(见 compiler 的
+ # `visit_contains_op_binary -> visit_like_op_binary`),
+ # 对 JSON 数组列等于在匹配 `'["RW-015"]'` 这个字符串,
+ # 于是 `rule_code` 筛选**恒返回 0 条**,而且不报任何错。
+ conditions.append(
+ func.json_contains(FundRiskAlert.trigger_rule_codes, json.dumps(rule_code))
+ )
if start_time is not None:
conditions.append(FundRiskAlert.created_at >= start_time)
if end_time is not None:
diff --git a/app/service/risk_scan_service.py b/app/service/risk_scan_service.py
index 9a203da..761bef5 100644
--- a/app/service/risk_scan_service.py
+++ b/app/service/risk_scan_service.py
@@ -7,6 +7,7 @@
from __future__ import annotations
import asyncio
+import json
import logging
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
@@ -452,7 +453,9 @@ class RiskRuleEngine:
return await self.session.scalar(
select(FundRiskAlert.id).where(
FundRiskAlert.related_transaction_id == transaction_id,
- FundRiskAlert.trigger_rule_codes.contains([rule_code]),
+ # 同 risk_repository:`.contains()` 会被编译成 `LIKE`,
+ # 对 JSON 数组列永远不匹配,去重就形同失效。
+ func.json_contains(FundRiskAlert.trigger_rule_codes, json.dumps(rule_code)),
)
) is not None
diff --git a/app/service/trade_service.py b/app/service/trade_service.py
index d9aae4e..174075f 100644
--- a/app/service/trade_service.py
+++ b/app/service/trade_service.py
@@ -24,6 +24,7 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import ROUND_HALF_UP, Decimal
+from typing import Any
from uuid import uuid4
from sqlalchemy import func, select
@@ -105,7 +106,7 @@ class TradeService:
self._session = session
self._suitability_evaluator = suitability_evaluator
- async def _next_id(self, model: type) -> int:
+ async def _next_id(self, model: Any) -> int:
"""返回 ``model`` 表的下一个可用主键。
底座 ``fin_*`` 表 ``id`` 列实际**未**配置 AUTO_INCREMENT(与 ``docs/00`` 设计稿
From 6155f4589e503a8c2a9410c1d2b67c88b030bdde Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com>
Date: Sat, 12 Sep 2026 16:30:54 +0800
Subject: [PATCH 5/9] =?UTF-8?q?feat(portal):=20=E6=8C=89=E9=A3=8E=E6=8E=A7?=
=?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=90=88=E5=B9=B6=E7=BA=A6=E6=9D=9F=E9=87=8D?=
=?UTF-8?q?=E5=86=99=E9=A3=8E=E6=8E=A7=E5=B7=A5=E4=BD=9C=E5=8F=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 概览五指标(总量/等级/待处理/超时/重点预警)
- 预警队列:每页 5 条、风险等级优先、8 项筛选、游标分页、稳定行高与省略号
- 预警详情弹窗:23 个 alert 字段 + 客户信息 + 五个处置动作
- 处置:确认接收(二次确认)/进入调查/关闭误报(必填理由)/结案(必填结论)/升级(必填原因)
- 八类证据(customers/products/transactions/capital_flows/holdings/login_records/
alerts/notifications)+ 五项筛选;注意是 holdings 不是 positions
- 通知记录、日报 SSE 流式生成 + 内容编辑 + 多邮箱发送
- 新增 Toast + 模态框,替换全部 17 处原生 alert(17- 文档明令禁止原生弹窗)
- 风险等级筛选值改用 高/中/低(预警对象用「高」,概览 levels 用「高风险」,口径不一致)
---
tools/portal.py | 675 +++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 577 insertions(+), 98 deletions(-)
diff --git a/tools/portal.py b/tools/portal.py
index cd5ee8e..fac6d14 100644
--- a/tools/portal.py
+++ b/tools/portal.py
@@ -460,6 +460,61 @@ PAGE = r"""
input.q { padding: 6px 9px; border: 1px solid #c9d2dd; border-radius: 4px; font: inherit; font-size: 13px; }
.muted { color: #8fa0b5; font-size: 12.5px; }
.hidden { display: none; }
+ /* 表格:行高固定、超长省略(17- 文档要求) */
+ table.fixed { table-layout: fixed; }
+ table.fixed td, table.fixed th { height: 30px; line-height: 30px; padding: 0 9px;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+ table.fixed td.wrap { white-space: normal; line-height: 1.5; height: auto; padding: 6px 9px; }
+ /* 筛选栏 */
+ .filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end;
+ background: #f7f9fc; border: 1px solid #e6ecf3; border-radius: 6px;
+ padding: 10px 12px; margin-bottom: 12px; }
+ .filters label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: #5a6b7f; }
+ .filters input, .filters select { padding: 5px 8px; border: 1px solid #c9d2dd;
+ border-radius: 4px; font: inherit; font-size: 13px; min-width: 120px; }
+ /* 分页 */
+ .pager { display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 12.5px;
+ color: #5a6b7f; }
+ /* 风险等级徽章 */
+ .lv { display: inline-block; padding: 1px 7px; border-radius: 9px; font-size: 11.5px; }
+ .lv-高, .lv-high { background: #fdecea; color: #a8342a; }
+ .lv-中, .lv-medium { background: #fff4e5; color: #8a5a00; }
+ .lv-低, .lv-low { background: #e8eef6; color: #44536a; }
+ /* Toast */
+ #toasts { position: fixed; right: 18px; bottom: 18px; z-index: 9999;
+ display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
+ .toast { min-width: 240px; max-width: 420px; padding: 10px 14px; border-radius: 6px;
+ box-shadow: 0 4px 16px rgba(31,45,61,.18); font-size: 13px; line-height: 1.6;
+ background: #fff; border-left: 4px solid #1f6feb; white-space: pre-wrap; }
+ .toast.ok { border-left-color: #1a7f37; }
+ .toast.bad { border-left-color: #c0392b; }
+ .toast.warn { border-left-color: #e0a800; }
+ .toast b { display: block; margin-bottom: 2px; }
+ /* 模态框 */
+ #modal-back { position: fixed; inset: 0; background: rgba(15,23,32,.45); z-index: 9998;
+ display: none; align-items: center; justify-content: center; padding: 20px; }
+ #modal-back.on { display: flex; }
+ .modal { background: #fff; border-radius: 8px; width: min(860px, 100%);
+ max-height: 88vh; display: flex; flex-direction: column;
+ box-shadow: 0 12px 40px rgba(15,23,32,.3); }
+ .modal header { background: #fff; color: #1f2d3d; border-bottom: 1px solid #e6ecf3;
+ padding: 13px 18px; font-size: 15px; font-weight: 600; display: flex;
+ align-items: center; }
+ .modal .body { padding: 16px 18px; overflow: auto; }
+ .modal .foot { padding: 12px 18px; border-top: 1px solid #e6ecf3; display: flex;
+ gap: 8px; justify-content: flex-end; }
+ .kv { display: grid; grid-template-columns: 120px 1fr; gap: 6px 12px; font-size: 13px; }
+ .kv dt { color: #6b7c93; }
+ .kv dd { margin: 0; word-break: break-all; }
+ .modal textarea { width: 100%; min-height: 88px; padding: 8px; border: 1px solid #c9d2dd;
+ border-radius: 4px; font: inherit; font-size: 13px; resize: vertical; }
+ .tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
+ .tabs button { padding: 5px 11px; font-size: 12.5px; border: 1px solid #c9d2dd;
+ background: #fff; border-radius: 14px; cursor: pointer; }
+ .tabs button.on { background: #1f6feb; border-color: #1f6feb; color: #fff; }
+ .stream { background: #0d1117; color: #d6e2f0; padding: 12px; border-radius: 6px;
+ font-family: Consolas, monospace; font-size: 12.5px; white-space: pre-wrap;
+ max-height: 300px; overflow: auto; line-height: 1.6; }
@@ -481,6 +536,15 @@ PAGE = r"""
+
+
+
基金智能服务平台
@@ -515,6 +579,123 @@ async function jpost(url, body) {
return r.json();
}
+/* ---------------- 通用交互组件 ----------------
+ 17- 文档明确要求:不使用浏览器原生 alert/prompt 作为正式交互方案,
+ 不能只用控制台日志代替用户提示。这里用 Toast + 模态框替代。 */
+
+function toast(text, kind) {
+ const box = $('toasts');
+ const el = document.createElement('div');
+ el.className = 'toast ' + (kind || '');
+ el.textContent = text;
+ box.appendChild(el);
+ setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; }, 3200);
+ setTimeout(() => el.remove(), 3600);
+}
+
+function ok(text) { toast(text, 'ok'); }
+function bad(text) { toast(text, 'bad'); }
+function warn(text) { toast(text, 'warn'); }
+
+function openModal(title, bodyHtml, footHtml) {
+ $('modal-title').textContent = title;
+ $('modal-body').innerHTML = bodyHtml;
+ $('modal-foot').innerHTML = footHtml || '';
+ $('modal-back').classList.add('on');
+}
+function closeModal() { $('modal-back').classList.remove('on'); }
+
+/** 替代 confirm:返回 Promise。 */
+function askConfirm(title, text, okLabel) {
+ return new Promise((resolve) => {
+ openModal(title, `${esc(text)}
`,
+ `
+ `);
+ window.__ask = resolve;
+ });
+}
+
+/** 替代 prompt:返回 Promise。 */
+function askText(title, label, options) {
+ const opts = options || {};
+ if (opts.choices) {
+ return new Promise((resolve) => {
+ const sel = opts.choices.map((c) => ``).join('');
+ openModal(title,
+ `
+ `,
+ `
+ `);
+ window.__ask = resolve;
+ });
+ }
+ return new Promise((resolve) => {
+ openModal(title,
+ `
+
+ ${esc(opts.hint || '')}
`,
+ `
+ `);
+ window.__ask = resolve;
+ });
+}
+
+/* ---------------- 统一响应处理(17- 文档 §六) ----------------
+ 失败提示解析顺序:error.message -> HTTP 状态码映射 -> 通用失败提示。
+ 注意本平台**业务失败也返回 HTTP 200**,错误在 body.code 里。 */
+
+const HTTP_TEXT = {
+ 401: '登录已失效,请重新登录',
+ 403: '没有当前操作权限',
+ 404: '记录不存在或无权查看',
+ 409: '当前状态不允许该操作',
+ 413: '文件超过大小限制',
+ 422: '提交内容不符合要求',
+ 500: '系统异常,请稍后重试',
+ 503: '服务暂时不可用',
+ 504: '服务暂时不可用',
+};
+
+/** 解析出「这次调用到底成没成」,并给出可直接展示的话。 */
+function judge(r) {
+ const body = (r && r.body) || {};
+ const err = body.error || {};
+ const code = body.code;
+ const biz = (code === undefined || code === null) ? null : Number(code);
+ const httpBad = !r || r.status >= 400 || r.status === 0;
+ const bizBad = (biz !== null && biz !== 0) || !!err.code;
+ const failed = httpBad || bizBad;
+
+ let reason = '';
+ if (err.message) reason = err.message;
+ else if (bizBad && body.message) reason = body.message;
+ else if (bizBad && biz !== null) reason = HTTP_TEXT[biz] || (body.message || '');
+ if (!reason && httpBad) reason = HTTP_TEXT[(r || {}).status] || '请求失败';
+ if (!failed) return { failed: false, reason: '' };
+
+ const details = (body.error && body.error.field_errors) || body.field_errors || [];
+ if (details.length) {
+ reason += ':' + details.map((d) => `${d.field} ${d.message}`).join(';');
+ }
+ return { failed: true, reason: reason || '请求失败' };
+}
+
+/** 按 17- 文档 §六 给出「操作名 + 成功/失败」的提示文案。 */
+function report(action, r, successText) {
+ const verdict = judge(r);
+ if (verdict.failed) {
+ const text = verdict.reason || '请求失败';
+ if ((r || {}).status === 409) {
+ bad(`${action}未完成:${text}\n可能已被处理,请刷新后核对状态`);
+ } else {
+ bad(`${action}失败:${text}`);
+ }
+ return false;
+ }
+ ok(successText || `${action}成功`);
+ return true;
+}
+
// 统一调用平台接口:令牌在服务端,前端只传方法与路径
async function api(method, path, body, query) {
return jpost('/api/call', { method, path, body, query });
@@ -630,7 +811,7 @@ async function ensureSession() {
body:{ agent_type:'customer_service' } });
const d = (r.body || {}).data || {};
CONV = d.session_id || d.id || null;
- if (!CONV) { alert('创建会话失败(HTTP ' + r.status + '):' + pretty(r.body)); }
+ if (!CONV) { bad('创建会话失败(HTTP ' + r.status + '):' + pretty(r.body)); }
return CONV;
}
@@ -685,7 +866,7 @@ async function myCandidates() {
async function decide(id, decision) {
const r = await jpost('/api/call', { method:'POST',
path: `/api/v1/users/me/memory-candidates/${id}/decisions`, body: { decision } });
- alert(`HTTP ${r.status}\n` + pretty(r.body));
+ bad(`HTTP ${r.status}\n` + pretty(r.body));
myCandidates();
}
@@ -708,114 +889,412 @@ function showExtra(title, r) {
${esc(pretty(r.body))}`;
}
-/* ---------------- 员工 · 风控工作台 ---------------- */
+/* ---------------- 员工 · 风控工作台 ----------------
+ 按 docs/风控业务演示文档/17-前端合并提示词与验收约束.md 实现:
+ 概览五指标、预警队列每页 5 条 + 风险等级优先 + 筛选 + 游标分页 + 详情弹窗、
+ 五个处置动作(确认接收二次确认、误报必填理由)、八类证据、通知、日报流式。
+ 交互上不用原生 alert/prompt —— 改用 Toast 与模态框。 */
+
+const EVIDENCE_SOURCES = [
+ ['customers', '客户'], ['products', '产品'], ['transactions', '交易'],
+ ['capital_flows', '资金'], ['holdings', '持仓'], ['login_records', '登录'],
+ ['alerts', '预警'], ['notifications', '通知'],
+];
+// 注意是 `holdings` 而不是 positions —— 传错会被 422 拒绝。
+const LEVEL_RANK = { '高风险': 0, '高': 0, '中风险': 1, '中': 1, '低风险': 2, '低': 2 };
+const RISK_PAGE_SIZE = 5;
+let RISK_CURSOR = null;
+let RISK_CURSOR_STACK = [];
+let RISK_HAS_MORE = false;
+let RISK_ITEMS = [];
+let RISK_DETAIL = null;
+
function renderStaff(box) {
box.innerHTML = `
-
风控工作台
-
只对 risk_operator / operator 开放。数据范围 all:
- 能看到全部客户的预警。处置类操作会写审计。
-
+
风险概览
+
数据范围 all(风控专员可看全部客户)。五个指标取自
+ GET /api/v1/risk/overview。
+
-
-
-
+
+
+
+
+
+
+
`;
loadRisk();
- loadAlerts();
+ loadAlerts(null);
+}
+
+function resetRiskFilters() {
+ ['keyword', 'customer_no', 'risk_level', 'rule_code', 'product_code',
+ 'product_name', 'start_time', 'end_time'].forEach((k) => { $('f-' + k).value = ''; });
+ loadAlerts(null);
+}
+
+function riskQuery() {
+ const q = {};
+ ['keyword', 'customer_no', 'risk_level', 'rule_code', 'product_code',
+ 'product_name', 'start_time', 'end_time'].forEach((k) => {
+ const v = ($('f-' + k) || {}).value;
+ if (v && v.trim()) q[k] = v.trim();
+ });
+ q.limit = RISK_PAGE_SIZE; // 该接口 limit 上限就是 5
+ return q;
}
async function loadRisk() {
const r = await GET('/api/v1/risk/overview');
- const d = r.body?.data || {};
- const entries = Object.entries(d).filter(([, v]) => typeof v !== 'object');
- $('overview').innerHTML = entries.length
- ? entries.map(([k, v]) => ``).join('')
- : ``;
- if (!entries.length) $('staff-extra').innerHTML =
- `总览原始返回
${esc(pretty(r.body))}`;
-}
-
-async function loadAlerts() {
- // 刻意**不传 limit**:该接口的 limit 上限是 5(传 20 会 422
- // `query.limit: Input should be less than or equal to 5`),
- // 不传则用平台默认值,最稳。
- const r = await GET('/api/v1/risk/alerts');
- const items = Array.isArray(r.body?.data) ? r.body.data
- : (r.body?.data?.items || []);
- const box = $('alerts');
- if (!Array.isArray(items) || !items.length) {
- box.innerHTML = `没有预警数据(HTTP ${r.status}${r.body?.code ? ' code=' + r.body.code : ''})。可先点「触发一次扫描」。
- ${esc(pretty(r.body))}`;
+ const d = (r.body || {}).data || {};
+ const verdict = judge(r);
+ if (verdict.failed) {
+ $('risk-overview').innerHTML = ``;
return;
}
- box.innerHTML = `| 预警号 | 客户 | 等级 | 规则 | 状态 | 操作 |
`
- + items.map((a) => {
- const no = esc(a.alert_no ?? a.alert_id ?? '');
- const rules = Array.isArray(a.rule_codes) ? a.rule_codes.join(', ') : (a.rule_code ?? '');
- return `
- ${no} |
- ${esc(a.customer_name || a.customer_no || a.customer_id || '')} |
- ${esc(a.risk_level ?? a.level ?? '')} |
- ${esc(rules)} |
- ${esc(a.status ?? '')} |
-
-
-
-
- |
`;
- }).join('')
- + '
';
+ const levels = d.levels || {};
+ const hi = (d.high_priority || []).length;
+ const cards = [
+ ['预警总量', d.total],
+ ['待处理', d.pending],
+ ['已超时', d.overdue],
+ ['高风险', levels['高风险'] ?? levels['高'] ?? 0],
+ ['重点预警', hi],
+ ];
+ $('risk-overview').innerHTML = cards.map(([label, value]) =>
+ `${esc(value ?? '—')}
${esc(label)}
`).join('');
+}
+
+async function loadAlerts(cursor) {
+ if (cursor === null || cursor === undefined) { RISK_CURSOR_STACK = []; }
+ const q = riskQuery();
+ if (cursor) q.cursor = cursor;
+ const r = await GET('/api/v1/risk/alerts', q);
+ const verdict = judge(r);
+ const box = $('alerts');
+ if (verdict.failed) {
+ box.innerHTML = `加载失败:${esc(verdict.reason)}
`;
+ $('pg-info').textContent = '';
+ return;
+ }
+ const data = (r.body || {}).data;
+ const items = Array.isArray(data) ? data : ((data || {}).items || []);
+ const meta = (r.body || {}).meta || {};
+ RISK_HAS_MORE = !!meta.has_more;
+ RISK_CURSOR = meta.next_cursor || null;
+ // 页内按风险等级优先,同级按 priority_score 降序
+ RISK_ITEMS = items.slice().sort((a, b) => {
+ const ra = LEVEL_RANK[a.risk_level] ?? 9;
+ const rb = LEVEL_RANK[b.risk_level] ?? 9;
+ if (ra !== rb) return ra - rb;
+ return (b.priority_score || 0) - (a.priority_score || 0);
+ });
+ $('risk-count').textContent = `(本页 ${RISK_ITEMS.length} 条)`;
+
+ if (!RISK_ITEMS.length) {
+ box.innerHTML = '没有符合条件的预警。可调整筛选条件或点「手动扫描」。
';
+ } else {
+ box.innerHTML = `
+ | 预警号 | 客户 |
+ 等级 | 规则 |
+ 处置状态 | 回执 |
+ 摘要 | 操作 |
`
+ + RISK_ITEMS.map((a) => {
+ const no = esc(a.alert_no || '');
+ const lv = esc(a.risk_level || '');
+ const rules = (a.rule_codes || []).join(', ');
+ const ack = a.ack_status === '已确认' ? '已确认' : '未确认';
+ const escMark = a.is_escalated ? '已升级' : '';
+ return `
+ ${no} |
+ ${esc(a.customer_name || a.customer_no || '')} |
+ ${lv} |
+ ${esc(rules)} |
+ ${esc(a.status || '')} |
+ ${esc(ack)} ${escMark} |
+ ${esc(a.evidence_summary || '')} |
+
+
+
+ |
`;
+ }).join('')
+ + '
';
+ }
+ $('pg-prev').disabled = RISK_CURSOR_STACK.length === 0;
+ $('pg-next').disabled = !RISK_HAS_MORE;
+ $('pg-info').textContent =
+ `第 ${RISK_CURSOR_STACK.length + 1} 页 · 每页 ${RISK_PAGE_SIZE} 条` + (RISK_HAS_MORE ? ' · 还有下一页' : ' · 已到末页');
+}
+
+function riskNextPage() {
+ if (!RISK_HAS_MORE) return;
+ RISK_CURSOR_STACK.push(RISK_CURSOR);
+ loadAlerts(RISK_CURSOR);
+}
+function riskPrevPage() {
+ if (!RISK_CURSOR_STACK.length) return;
+ const prev = RISK_CURSOR_STACK.pop();
+ loadAlerts(RISK_CURSOR_STACK.length ? prev : null);
+}
+
+function levelPill(lv) {
+ const t = esc(lv || '');
+ return `${t}`;
+}
+
+async function openAlert(no) {
+ const r = await GET(`/api/v1/risk/alerts/${no}`);
+ const verdict = judge(r);
+ if (verdict.failed) { bad(`打开预警详情失败:${verdict.reason}`); return; }
+ const d = (r.body || {}).data || {};
+ const a = d.alert || {};
+ const c = d.customer || {};
+ RISK_DETAIL = a;
+ const ack = a.ack_status === '已确认';
+ const snap = a.evidence_snapshot || {};
+ openModal(`预警详情 ${a.alert_no || no}`, `
+
+ - 预警编号
${esc(a.alert_no)}
+ - 预警类型
- ${esc(a.alert_type)}
+ - 风险等级
- ${levelPill(a.risk_level)} · 优先级分 ${esc(a.priority_score)}
+ - 处置状态
- ${esc(a.status)} ${a.is_escalated ? '(已升级标记)' : ''}
+ - 回执状态
- ${esc(a.ack_status)}${a.ack_at ? ' @ ' + esc(a.ack_at) : ''}
+ - 证据归档
- ${a.evidence_archived ? '已归档' : '未归档'}
+ - 规则
- ${(a.rule_codes || []).map((x) => `${esc(x)}`).join('')}
+ - 到期时间
- ${esc(a.due_at)}
+ - 结案原因
- ${esc(a.close_reason || '—')}
+ - 证据摘要
- ${esc(a.evidence_summary)}
+ - 证据快照
${esc(pretty(snap))}
+ - 客户
- ${esc(c.name)}(${esc(c.customer_no)})· 投资者类型 ${esc(c.investor_type)} ·
+ 行为分 ${esc(c.behavior_score)} · 总资产 ${esc(c.total_asset)}
+ - 风险标签
- ${esc(c.risk_tags || '—')}
+
`,
+ `
+
+
+
+
+ `);
+}
+
+/* 五个处置动作。写操作都走二次确认,误报/升级/结案必填文字。 */
+async function ack(no) {
+ if (!await askConfirm('确认接收', `确认接收预警 ${no}?\n确认后才会进入可处置状态。`, '确认接收')) return;
+ const r = await jpost('/api/call',
+ { method:'POST', path:`/api/v1/risk/alerts/${no}/acknowledgements`, body:{} });
+ if (report('确认接收', r, '预警已确认接收')) { loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null); loadRisk(); }
+}
+
+async function investigate(no) {
+ if (!await askConfirm('进入调查', `将预警 ${no} 标记为调查中?`, '进入调查')) return;
+ const r = await jpost('/api/call',
+ { method:'POST', path:`/api/v1/risk/alerts/${no}/investigations`, body:{} });
+ if (report('进入调查', r, '已进入调查')) loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null);
+}
+
+async function exclude(no) {
+ const reason = await askText('关闭误报', '误报理由(必填,1-500 字)',
+ { value: '', maxlength: 500, hint: '本题为必填项,平台会校验。' });
+ if (reason === null) return;
+ if (!reason.trim()) { bad('误报理由不能为空'); return; }
+ const r = await jpost('/api/call',
+ { method:'POST', path:`/api/v1/risk/alerts/${no}/exclusions`, body:{ reason: reason.trim() } });
+ if (report('关闭误报', r, '已关闭误报')) loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null);
+}
+
+async function escalate(no) {
+ const reason = await askText('升级处理', '升级原因(必填,1-500 字)',
+ { value: '客户风险等级需人工复核', maxlength: 500 });
+ if (reason === null) return;
+ if (!reason.trim()) { bad('升级原因不能为空'); return; }
+ const r = await jpost('/api/call',
+ { method:'POST', path:`/api/v1/risk/alerts/${no}/escalations`, body:{ reason: reason.trim() } });
+ if (report('升级处理', r, '预警已升级')) loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null);
+}
+
+async function resolveAlert(no) {
+ const resolution = await askText('完成结案', '处理结论(必填,1-500 字)',
+ { value: '已联系客户核实,风险已排除', maxlength: 500 });
+ if (resolution === null) return;
+ if (!resolution.trim()) { bad('处理结论不能为空'); return; }
+ const r = await jpost('/api/call',
+ { method:'POST', path:`/api/v1/risk/alerts/${no}/resolutions`, body:{ resolution: resolution.trim() } });
+ if (report('完成结案', r, '预警已完成结案')) { loadAlerts(RISK_CURSOR_STACK.length ? RISK_CURSOR : null); loadRisk(); }
}
async function scanRisk() {
+ if (!await askConfirm('手动扫描', '触发一次风控规则扫描?会产生新的预警与审计记录。', '开始扫描')) return;
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/risk/alerts/scan', body:{} });
- showExtra2('扫描结果', r);
- loadAlerts();
+ if (report('手动扫描', r, '预警扫描完成')) { loadRisk(); loadAlerts(null); }
}
-async function dailyReport() {
- const r = await jpost('/api/call', { method:'POST', path:'/api/v1/risk/daily-report', body:{} });
- showExtra2('日报结果', r);
+/* 日报:SSE 流式生成 -> 可编辑 -> 多邮箱发送 */
+async function openDailyReport() {
+ openModal('生成风控日报', `
+
+ 使用 POST /api/v1/risk/daily-report/stream(SSE)
+ (尚未开始)
+
+
+
+
+
+
+
+ 发送接口要 recipients / subject / content 三个字段。
`);
}
-async function ack(no) { await act_(no, 'acknowledgements', '确认'); }
-
-async function esc_(no) { // 升级:接口要求 reason(1-500 字)
- const reason = prompt('升级原因(必填,最多 500 字):', '客户风险等级需人工复核');
- if (reason === null) return;
- if (!reason.trim()) return alert('升级原因不能为空');
- await act_(no, 'escalations', '升级', { reason: reason.slice(0, 500) });
+async function streamDailyReport() {
+ const box = $('dr-stream');
+ box.textContent = '';
+ try {
+ const resp = await fetch('/api/call', {
+ method: 'POST', headers: HEAD(),
+ body: JSON.stringify({ method:'POST', path:'/api/v1/risk/daily-report/stream', body:{} }),
+ });
+ const payload = await resp.json();
+ const raw = ((payload.body || {})._raw) || '';
+ if (!raw) {
+ box.textContent = pretty(payload.body);
+ const verdict = judge(payload);
+ if (verdict.failed) bad('日报生成失败:' + verdict.reason);
+ return;
+ }
+ // 逐条解析 SSE:event: xxx / data: {...}
+ let content = '';
+ raw.split('\n').forEach((line) => {
+ if (line.startsWith('data:')) {
+ try {
+ const obj = JSON.parse(line.slice(5).trim());
+ if (obj.type === 'replace' && obj.content) content = obj.content;
+ else if (obj.content) content += obj.content;
+ if (obj.message) box.textContent += `[${obj.stage || obj.type}] ${obj.message}\n`;
+ } catch { /* 忽略非 JSON 行 */ }
+ } else if (line.startsWith('event:')) {
+ box.textContent += `── ${line.slice(6).trim()} ──\n`;
+ }
+ });
+ if (content) { $('dr-content').value = content; ok('日报生成完成'); }
+ else warn('日报流已结束,但没有解析到内容');
+ } catch (err) {
+ bad('日报流式请求失败:' + err.message);
+ }
}
-async function resolve(no) { // 解决:字段名是 resolution,不是 reason
- const resolution = prompt('处理结论(必填,最多 500 字):', '已联系客户核实,风险已排除');
- if (resolution === null) return;
- if (!resolution.trim()) return alert('处理结论不能为空');
- await act_(no, 'resolutions', '解决', { resolution: resolution.slice(0, 500) });
+async function sendDailyReport() {
+ const content = $('dr-content').value.trim();
+ const subject = $('dr-subject').value.trim();
+ const to = $('dr-to').value.split(',').map((x) => x.trim()).filter(Boolean);
+ if (!content) { bad('日报内容为空,先生成或填写'); return; }
+ if (!to.length) { bad('请至少填一个收件人'); return; }
+ if (!await askConfirm('发送日报', `发送给 ${to.join(', ')}?`, '发送')) return;
+ const r = await jpost('/api/call', { method:'POST', path:'/api/v1/risk/daily-report/mail',
+ body:{ recipients: to, subject, content } });
+ report('邮件发送', r, '日报发送成功');
}
-async function act_(no, action, label, body) {
- if (!confirm(`对预警 ${no} 执行「${label}」?这会写审计。`)) return;
- const r = await jpost('/api/call', { method:'POST',
- path: `/api/v1/risk/alerts/${no}/${action}`, body: body || {} });
- showExtra2(`${label} ${no}`, r);
- loadAlerts();
+/* 八类证据 */
+async function openEvidence() {
+ openModal('八类证据', `
+ ${EVIDENCE_SOURCES.map(([k, label], i) =>
+ ``).join('')}
+
+
+
+
+
+
+
+
+ `,
+ '');
+ loadEvidence('customers');
}
-function showExtra2(title, r) {
- $('staff-extra').innerHTML = `${esc(title)}
- HTTP ${r.status}${r.status === 403 ? ' —— 权限不足(fail closed)' : ''}
- ${esc(pretty(r.body))}`;
+let EV_SOURCE = 'customers';
+async function loadEvidence(source, btn) {
+ EV_SOURCE = source;
+ if (btn) {
+ document.querySelectorAll('#ev-tabs button').forEach((b) => b.classList.remove('on'));
+ btn.classList.add('on');
+ }
+ const q = {};
+ ['keyword', 'behavior_level', 'send_status', 'start_time', 'end_time'].forEach((k) => {
+ const v = ($('ev-' + k) || {}).value;
+ if (v && v.trim()) q[k] = v.trim();
+ });
+ const r = await GET(`/api/v1/risk/evidence/${source}`, q);
+ const verdict = judge(r);
+ const box = $('ev-body');
+ if (verdict.failed) { box.innerHTML = `加载失败:${esc(verdict.reason)}
`; return; }
+ const data = (r.body || {}).data;
+ const items = Array.isArray(data) ? data : ((data || {}).items || []);
+ if (!items.length) { box.innerHTML = '该类证据暂无数据。
'; return; }
+ const cols = Object.keys(items[0]);
+ box.innerHTML = `${items.length} 条 · 来源
+ ${esc(source)}
+ ${cols.map((c) => `| ${esc(c)} | `).join('')}
`
+ + items.slice(0, 30).map((row) => '' + cols.map((c) => {
+ const v = row[c];
+ const text = (v === null || v === undefined) ? '' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
+ return `| ${esc(text)} | `;
+ }).join('') + '
').join('')
+ + '
';
+}
+
+function reloadEvidence() { loadEvidence(EV_SOURCE); }
+
+/* 通知记录 */
+async function openNotifications() {
+ openModal('通知记录', '',
+ '');
+ const r = await GET('/api/v1/risk/notifications');
+ const verdict = judge(r);
+ if (verdict.failed) { $('nt-body').innerHTML = `加载失败:${esc(verdict.reason)}
`; return; }
+ const data = (r.body || {}).data;
+ const items = Array.isArray(data) ? data : ((data || {}).items || []);
+ $('nt-body').innerHTML = items.length
+ ? `| 预警编号 | 类型 | 发送状态 | 时间 |
`
+ + items.map((n) => `${esc(n.alert_no ?? '')} |
+ ${esc(n.notification_type ?? n.type ?? '')} |
+ ${esc(n.send_status ?? n.status ?? '')} |
+ ${esc(n.created_at ?? n.sent_at ?? '')} |
`).join('')
+ + '
'
+ : '暂无通知记录。
';
}
/* ---------------- 员工 · 运营工作台(场外基金) ---------------- */
@@ -904,20 +1383,20 @@ async function recoverMailbox() {
body:{ operator_id: myId() } }));
}
async function docFields() {
- const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
+ const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
showOffsite('识别字段 ' + t, await GET(`/api/v1/offsite-fund/documents/${t}/nl2sql-fields`));
}
async function docRules() {
- const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
+ const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
showOffsite('规则结果 ' + t, await GET(`/api/v1/offsite-fund/documents/${t}/rule-results`));
}
async function docConfirm() {
- const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
+ const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
// decision 是**中文枚举**:确认无误 / 确认异常 / 未处理
const decision = prompt('确认结论(确认无误 / 确认异常 / 未处理):', '确认无误');
if (decision === null) return;
if (['确认无误', '确认异常', '未处理'].indexOf(decision) < 0) {
- return alert('只能是:确认无误 / 确认异常 / 未处理');
+ return bad('只能是:确认无误 / 确认异常 / 未处理');
}
if (!confirm(`对单据 ${t} 提交「${decision}」?这是写操作,会进审计。`)) return;
showOffsite('确认单据 ' + t, await jpost('/api/call',
@@ -925,13 +1404,13 @@ async function docConfirm() {
body:{ decision, operator_id: myId() } }));
}
async function docRetry() {
- const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
+ const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
showOffsite('重试识别 ' + t, await jpost('/api/call',
{ method:'POST', path:`/api/v1/offsite-fund/documents/${t}/recognition-retries`,
body:{ operator_id: myId() } }));
}
async function docNotify() {
- const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
+ const t = $('task').value.trim(); if (!t) return bad('请先填单据号');
// notification_type 取值:risk / settlement / mail_return / normal_return / exception_return…
const type = prompt('通知类型(risk / settlement / mail_return / normal_return / exception_return):',
'normal_return');
@@ -947,7 +1426,7 @@ async function settle() {
if (fund === null) return;
const date = prompt('申请日期 application_date(YYYY-MM-DD):', '');
if (date === null) return;
- if (!fund.trim() || !date.trim()) return alert('基金代码与申请日期都必填');
+ if (!fund.trim() || !date.trim()) return bad('基金代码与申请日期都必填');
if (!confirm(`重算 ${fund} 在 ${date} 的结算统计?这是写操作。`)) return;
showOffsite('结算重算', await jpost('/api/call',
{ method:'POST', path:'/api/v1/offsite-fund/settlement-statistics/recalculate',
@@ -1100,7 +1579,7 @@ async function loadKnowledge() {
async function admPromoLookup() {
const t = $('promo-task').value.trim();
- if (!t) return alert('请填任务单号');
+ if (!t) return bad('请填任务单号');
const r = await GET(`/api/v1/fund-promotion-materials/${t}`);
const mv = ((r.body || {}).data || {}).material_version || {};
if (mv.id) $('adm-version').value = mv.id;
@@ -1112,8 +1591,8 @@ async function admPromoLookup() {
async function admReview(decision) {
const t = $('promo-task').value.trim();
const vid = parseInt($('adm-version').value.trim(), 10);
- if (!t) return alert('请填任务单号');
- if (!vid) return alert('请填 material_version_id(可先点「查任务」自动填)');
+ if (!t) return bad('请填任务单号');
+ if (!vid) return bad('请填 material_version_id(可先点「查任务」自动填)');
const comment = $('adm-comment').value.trim() || null;
if (!confirm(`对 ${t} 的版本 ${vid} 执行「${decision}」?`)) return;
const r = await jpost('/api/call', { method:'POST',
@@ -1221,7 +1700,7 @@ function promoSkeleton() {
function promoTaskNo() {
const t = $('promo-task').value.trim();
- if (!t) { alert('请先填任务单号(创建任务后会返回)'); return null; }
+ if (!t) { bad('请先填任务单号(创建任务后会返回)'); return null; }
return t;
}
@@ -1229,7 +1708,7 @@ async function promoCreate() {
const name = $('promo-name').value.trim();
const title = $('promo-title').value.trim();
const style = $('promo-style').value.trim();
- if (!name || !title || !style) return alert('产品名、材料标题、风格代码都要填');
+ if (!name || !title || !style) return bad('产品名、材料标题、风格代码都要填');
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/fund-promotion-materials',
body:{ product_name: name, material_title: title, style_code: style } });
const d = (r.body || {}).data || {};
@@ -1241,7 +1720,7 @@ async function promoSaveInputs() {
const t = promoTaskNo(); if (!t) return;
let body;
try { body = JSON.parse($('promo-inputs').value); }
- catch (e) { return alert('结构化输入不是合法 JSON:' + e.message); }
+ catch (e) { return bad('结构化输入不是合法 JSON:' + e.message); }
const r = await jpost('/api/call', { method:'PUT',
path:`/api/v1/fund-promotion-materials/${t}/inputs`, body });
showAdv('② 保存结构化输入 ' + t, r);
@@ -1272,9 +1751,9 @@ async function promoGenerate(fmt) {
async function promoDeliver() {
const t = promoTaskNo(); if (!t) return;
const versionId = parseInt($('promo-version').value.trim(), 10);
- if (!versionId) return alert('请填 material_version_id(先做管理员审核,审核通过后从任务详情里拿)');
+ if (!versionId) return bad('请填 material_version_id(先做管理员审核,审核通过后从任务详情里拿)');
const ids = $('promo-advisors').value.split(',').map((x) => parseInt(x.trim(), 10)).filter((x) => x);
- if (!ids.length) return alert('请填要投递的投顾 id');
+ if (!ids.length) return bad('请填要投递的投顾 id');
if (!confirm(`把版本 ${versionId} 投递给投顾 ${ids.join(', ')}?`)) return;
const r = await jpost('/api/call', { method:'POST',
path:`/api/v1/fund-promotion-materials/${t}/deliveries`,
@@ -1326,19 +1805,19 @@ function result(title, r) {
const code = body.code;
const err = body.error || {};
const biz = (code === undefined || code === null) ? null : Number(code);
- const bad = (r.status >= 400) || (biz !== null && biz !== 0) || !!err.code;
+ const isBad = (r.status >= 400) || (biz !== null && biz !== 0) || !!err.code;
const bits = [`HTTP ${r.status}`];
if (biz !== null) bits.push(`body.code ${biz}`);
if (err.code) bits.push(`error ${esc(err.code)}`);
let extra = '';
if (err.message) extra = esc(err.message);
- else if (bad && body.message) extra = esc(body.message);
- if (!bad) {
+ else if (isBad && body.message) extra = esc(body.message);
+ if (!isBad) {
if (r.status === 403) extra = '当前角色权限不足(平台按设计 fail closed)';
else if (r.status === 404) extra = '资源不存在或不可见(见下方 message)';
}
return `${esc(title)}
- ${bits.join(' · ')}${extra ? ' —— ' + extra : ''}
+ ${bits.join(' · ')}${extra ? ' —— ' + extra : ''}
${esc(pretty(body))}`;
}
From 280c8e502609149e29889038c930bdddb7b738d0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com>
Date: Sat, 12 Sep 2026 16:31:55 +0800
Subject: [PATCH 6/9] =?UTF-8?q?docs:=20=E6=B8=85=E5=8D=95=E9=A3=8E?=
=?UTF-8?q?=E6=8E=A7=E7=AB=A0=E8=8A=82=E6=8C=89=2017-=20=E5=89=8D=E7=AB=AF?=
=?UTF-8?q?=E5=90=88=E5=B9=B6=E7=BA=A6=E6=9D=9F=E9=87=8D=E5=86=99=EF=BC=88?=
=?UTF-8?q?15=20=E9=A1=B9=EF=BC=8C=E5=90=AB=E5=AE=9E=E6=B5=8B=E8=AF=81?=
=?UTF-8?q?=E6=8D=AE=E4=B8=8E=E4=B8=89=E4=B8=AA=E5=9D=91=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/40-前端验收清单.md | 39 ++++++++++++++++++++++++++-------------
1 file changed, 26 insertions(+), 13 deletions(-)
diff --git a/docs/40-前端验收清单.md b/docs/40-前端验收清单.md
index 3aad0c0..b69b408 100644
--- a/docs/40-前端验收清单.md
+++ b/docs/40-前端验收清单.md
@@ -72,25 +72,38 @@ D:\conda\envs\jr_py313\python.exe tools\portal.py --base-url http://127.0.0.1:80
## 3. 员工 · 风控工作台(`risk_t`)
+> 本节按 `docs/风控业务演示文档/17-前端合并提示词与验收约束.md` 重写过,功能清单与交互约束都对齐了那份文档。
+
| # | 操作 | 预期结果 |
|---|---|---|
-| 3-1 | 进入即自动加载"总览" | 数字卡片;数据范围 `all`(能看全部客户的预警) ✅实测 |
-| 3-2 | 点「刷新总览」 | 同上,HTTP 200 ✅实测 |
-| 3-3 | 进入即自动加载"预警列表" | **表格出现**:预警号 / 客户 / 等级 / 规则 / 状态 / 操作。本机实测 2 条:`ALDEMO0002`(高,RW-015/RW-003)、`ALDEMO0001`(中,RW-007/RW-002/RW-012),均"待处理" ✅实测
⚠️ **门户刻意不传 `limit`**:该接口 `limit` 上限是 **5**,传 20 会得到 `422 query.limit: Input should be less than or equal to 5`,**整张表格渲染不出来**(行内按钮也随之消失) |
-| 3-4 | 点「触发一次扫描」 | **HTTP 200,`body.code=0`**(这条以前会因缺幂等头报 422,已修) ✅实测 |
-| 3-5 | 点「生成日报」 | HTTP 200,返回日报内容 ⚠️按契约 |
-| 3-6 | 对某条预警点「确认」 | 二次确认后返回业务结果。✅实测:`POST .../acknowledgements`(**无必填 body**)→ **409「只有待处理的预警才能确认解决」** —— 该动作**有状态前置条件**,不是任意状态都能点 |
-| 3-7 | 点「升级」/「解决」 | ✅实测:**必须先「确认」接收预警**,否则两者都返回 **409「请先确认接收预警」**。
升级要填 reason、解决要填 resolution(**字段名不同**,各 1-500 字),门户已做成弹窗必填;不填会是 422 |
-| 3-8 | 点一个**不存在**的预警号 | 404 资源不存在 —— 正常 fail closed ⚠️按契约 |
-| 3-9 | 用**客户**账号访问风控接口 | **403 缺少操作权限**(`risk_t` 能看,`cust_t` 不能) ✅实测(客户访问 `/admin/roles` 为 403) |
+| 3-1 | 进入即自动加载"风险概览" | 五张指标卡:**预警总量 / 待处理 / 已超时 / 高风险 / 重点预警** ✅实测(total=2 pending=1 overdue=2 高风险=2 重点=2) |
+| 3-2 | 看"预警队列" | **每页 5 条**、按风险等级优先、行高固定且超长省略 ✅实测
本机 2 条:`ALDEMO0002`(高,RW-015/RW-003)、`ALDEMO0001`(高,RW-007/RW-002/RW-012)
⚠️ 该接口 `limit` 上限就是 **5**,传 20 会 422 且**整张表渲染不出来** |
+| 3-3 | 用筛选栏逐项筛 | 8 个条件:关键词、客户号、风险等级、规则码、产品代码、产品名、起止时间 ✅实测
`rule_code=RW-015 → ALDEMO0002`、`customer_no=T-CUST → 2 条`、`keyword=ALDEMO0002 → 1 条`
⚠️ **风险等级必须填「高/中/低」**:预警对象用「高」,而概览 `levels` 用「高风险」,填「高风险」会 422 |
+| 3-4 | 点「下一页 / 上一页」 | 走游标分页(`meta.next_cursor` + `has_more`),页脚显示"第 N 页 · 每页 5 条" ✅实测(本机 2 条 → 已到末页) |
+| 3-5 | 点某行「详情」 | **弹窗**显示:预警编号、类型、等级与优先级分、处置状态、回执状态、证据归档、规则、到期时间、结案原因、证据摘要、证据快照、客户(行为分/投资者类型/风险标签)✅实测(alert 23 字段、customer 13 字段) |
+| 3-6 | 弹窗里点「确认接收」 | 二次确认后提交。✅实测:`POST .../acknowledgements`(无 body)→ **409「只有待处理的预警才能确认解决」**,说明该动作有状态前置 |
+| 3-7 | 弹窗里点「进入调查」 | 二次确认后 `POST .../investigations`(无 body)⚠️按契约 |
+| 3-8 | 弹窗里点「关闭误报」 | **必须填写理由**(1-500 字),空值会被前端与后端双重拒绝 → `POST .../exclusions {reason}` ✅实测(body 字段已核对) |
+| 3-9 | 弹窗里点「升级」 | **必须先「确认接收」**,否则 409「请先确认接收预警」;填 `reason` → `POST .../escalations` ✅实测 |
+| 3-10 | 弹窗里点「完成结案」 | 填 `resolution`(**字段名不是 reason**)→ `POST .../resolutions` ✅实测 |
+| 3-11 | 点「手动扫描」 | 二次确认后 `POST /alerts/scan` → **HTTP 200, code=0** ✅实测 |
+| 3-12 | 点「生成日报」 | 弹窗内**流式生成**(SSE:`start` / `progress` / `replace`),生成后可**编辑内容**再填收件人发送 ✅实测(事件类型已核对) |
+| 3-13 | 点「八类证据」 | 八个页签:**客户 / 产品 / 交易 / 资金 / 持仓 / 登录 / 预警 / 通知**,各带关键词、行为分等级、发送状态、起止时间筛选 ✅实测(8/8 全部 HTTP 200)
⚠️ 正确路径是 **`holdings`**,写成 `positions` 会被 422 拒绝 |
+| 3-14 | 点「通知记录」 | 表格:预警编号 / 类型 / 发送状态 / 时间 ✅实测(HTTP 200) |
+| 3-15 | 用**客户**账号访问风控接口 | **403 缺少操作权限** ✅实测 |
> ⚠️ **注意**:`risk_t` 有 `audit:read`,所以它访问 `/api/v1/admin/roles` 是 **200 而不是 403** ——
> 这是种子设计如此,不是越权漏洞。
>
-> ⚠️ 行内三条按钮对应 `acknowledgements` / `escalations` / `resolutions` 三个端点,都是**写操作**:
-> 会在库里留数据与审计,且**受状态机约束**(**确认 → 升级/解决**)。三个动作的请求体各不相同:
-> 确认无 body、升级要 `reason`、解决要 `resolution`。列表拿不到数据时这三个按钮不会出现 ——
-> 先确认 3-3 是否正常。
+> ⚠️ 五个处置动作都是**写操作**,会在库里留数据与审计,且**受状态机约束**(**先确认 → 再调查/误报/升级/结案**)。
+> 各自动作的请求体不同:确认与进入调查无 body、误报与升级要 `reason`、结案要 `resolution`。
+> 列表拿不到数据时行内按钮不会出现 —— 先确认 3-2 是否正常。
+>
+> ⚠️ **排序局限**:接口没有排序参数,门户只在**当前页内**按风险等级+优先级分排序;跨页整体排序需要服务端支持。
+>
+> ⚠️ 本轮顺带修掉一个平台 bug:`rule_code` 筛选此前**恒返回 0 条**(`risk_repository.py` 用
+> `.contains([code])`,SQLAlchemy 把它编译成 `LIKE`,而列是 JSON 数组,等于匹配字符串
+> `'["RW-015"]'`)。已改为 `func.json_contains(col, json.dumps(code))`,扫描去重处的同一写法也一并修了。
---
From bbe575f38fcf95571061fecf8dcc2962ab687890 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com>
Date: Sat, 12 Sep 2026 16:41:26 +0800
Subject: [PATCH 7/9] =?UTF-8?q?fix(portal):=20=E6=A8=A1=E6=80=81=E6=89=93?=
=?UTF-8?q?=E4=B8=8D=E5=BC=80=E6=97=B6=E7=AB=8B=E5=88=BB=20resolve?=
=?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=20await=20=E6=B0=B8=E4=B9=85?=
=?UTF-8?q?=E6=8C=82=E8=B5=B7=EF=BC=88=E8=A1=A8=E7=8E=B0=E4=B8=BA=E7=82=B9?=
=?UTF-8?q?=E5=87=BB=E6=B2=A1=E5=8F=8D=E5=BA=94=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- askConfirm/askText 在 openModal 失败时立即 resolve(false)/resolve(null):
此前 Promise 永不 resolve,await 会一直挂着,用户看到的就是点了没反应
- toast/openModal 在容器缺失时给出明确提示而不是静默失败
- 顶栏显示页面构建时间(取 portal.py 修改时间),用于一眼确认加载的不是缓存旧页
---
tools/portal.py | 31 +++++++++++++++++++++++++++----
1 file changed, 27 insertions(+), 4 deletions(-)
diff --git a/tools/portal.py b/tools/portal.py
index fac6d14..4486431 100644
--- a/tools/portal.py
+++ b/tools/portal.py
@@ -38,6 +38,7 @@ from __future__ import annotations
import argparse
import sys
import uuid
+from datetime import datetime
from pathlib import Path
from typing import Any
@@ -552,6 +553,7 @@ PAGE = r"""
+
@@ -583,8 +585,11 @@ async function jpost(url, body) {
17- 文档明确要求:不使用浏览器原生 alert/prompt 作为正式交互方案,
不能只用控制台日志代替用户提示。这里用 Toast + 模态框替代。 */
+const PAGE_BUILD = "__BUILD__";
+
function toast(text, kind) {
const box = $('toasts');
+ if (!box) { console.error('[portal] #toasts 缺失,退回原生提示'); (kind === 'bad' ? console.error : console.log)(text); return; }
const el = document.createElement('div');
el.className = 'toast ' + (kind || '');
el.textContent = text;
@@ -598,19 +603,29 @@ function bad(text) { toast(text, 'bad'); }
function warn(text) { toast(text, 'warn'); }
function openModal(title, bodyHtml, footHtml) {
+ const back = $('modal-back');
+ if (!back) {
+ // 兜底:容器缺失(多半是浏览器缓存了旧页面)时不要静默失败
+ console.error('[portal] #modal-back 缺失:页面可能是旧版本,请硬刷新(Ctrl+F5)');
+ window.alert(String(title) + '\n\n页面组件缺失,请硬刷新后再试(Ctrl+F5)');
+ return false;
+ }
$('modal-title').textContent = title;
$('modal-body').innerHTML = bodyHtml;
$('modal-foot').innerHTML = footHtml || '
';
- $('modal-back').classList.add('on');
+ back.classList.add('on');
+ return true;
}
function closeModal() { $('modal-back').classList.remove('on'); }
/** 替代 confirm:返回 Promise
。 */
function askConfirm(title, text, okLabel) {
return new Promise((resolve) => {
- openModal(title, `${esc(text)}
`,
+ const opened = openModal(title, `${esc(text)}
`,
`
`);
+ // 模态打不开时必须**立刻 resolve**,否则 await 永远挂着 —— 表现就是"点了没反应"
+ if (!opened) { resolve(false); return; }
window.__ask = resolve;
});
}
@@ -621,21 +636,23 @@ function askText(title, label, options) {
if (opts.choices) {
return new Promise((resolve) => {
const sel = opts.choices.map((c) => ``).join('');
- openModal(title,
+ const opened = openModal(title,
`
`,
`
`);
+ if (!opened) { resolve(null); return; }
window.__ask = resolve;
});
}
return new Promise((resolve) => {
- openModal(title,
+ const opened = openModal(title,
`
${esc(opts.hint || '')}
`,
`
`);
+ if (!opened) { resolve(null); return; }
window.__ask = resolve;
});
}
@@ -735,6 +752,7 @@ function showApp() {
$('who').textContent = ME.username + '(' + ME.user_id + ')';
$('role').textContent = ME.roles.join(' / ') || '无角色';
$('env').textContent = (ME.environment?.mode || '') + ' · ' + (ME.environment?.mysql || ME.environment?.target || '');
+ if ($('build')) $('build').textContent = 'build ' + PAGE_BUILD;
VIEW = ME.view;
const tabs = [{ id: VIEW, label: ME.view_label }];
// 多角色时允许手动切到其他已具备的界面,便于一次演示
@@ -1848,6 +1866,11 @@ boot();
"""
+#: 页面构建标记:取本文件的修改时间。刷新后这个值应当变化 ——
+#: 用它一眼确认浏览器加载的不是缓存旧页(旧页缺 Toast/模态容器时,点击会静默失效)。
+_PAGE_BUILD = datetime.fromtimestamp(Path(__file__).stat().st_mtime).strftime("%m-%d %H:%M")
+PAGE = PAGE.replace("__BUILD__", _PAGE_BUILD)
+
def main() -> None:
parser = argparse.ArgumentParser(description="统一登录门户(按角色分流)")
From d919b2ae5cf86c4c310cc61afcefa2ab2cc4b5c8 Mon Sep 17 00:00:00 2001
From: zhangshy <994452054@qq.com>
Date: Sat, 12 Sep 2026 16:56:27 +0800
Subject: [PATCH 8/9] =?UTF-8?q?=E9=87=8D=E5=91=BD=E5=90=8D=E9=A3=8E?=
=?UTF-8?q?=E6=8E=A7=E5=89=8D=E7=AB=AF=E5=90=88=E5=B9=B6=E6=96=87=E6=A1=A3?=
=?UTF-8?q?=E5=B9=B6=E6=9B=B4=E6=96=B0=E5=BC=95=E7=94=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...词与验收约束.md => 17-风控模块-前端合并提示词与验收约束.md} | 0
docs/风控业务演示文档/18-当前项目完成进度.md | 2 +-
2 files changed, 1 insertion(+), 1 deletion(-)
rename docs/风控业务演示文档/{17-前端合并提示词与验收约束.md => 17-风控模块-前端合并提示词与验收约束.md} (100%)
diff --git a/docs/风控业务演示文档/17-前端合并提示词与验收约束.md b/docs/风控业务演示文档/17-风控模块-前端合并提示词与验收约束.md
similarity index 100%
rename from docs/风控业务演示文档/17-前端合并提示词与验收约束.md
rename to docs/风控业务演示文档/17-风控模块-前端合并提示词与验收约束.md
diff --git a/docs/风控业务演示文档/18-当前项目完成进度.md b/docs/风控业务演示文档/18-当前项目完成进度.md
index 9d16b80..d2c3538 100644
--- a/docs/风控业务演示文档/18-当前项目完成进度.md
+++ b/docs/风控业务演示文档/18-当前项目完成进度.md
@@ -174,7 +174,7 @@
1. 将 `RM2_develop` 与最新 `qyqy_develop` 保持同步。
2. 将定时规则扫描和高风险预警邮件配置纳入主项目部署配置。
3. 将定时规则扫描接入主项目统一 Worker 或部署编排,取消业务人员手工启动独立进程。
-4. 按 `17-前端合并提示词与验收约束.md` 合并正式前端。
+4. 按 `17-风控模块-前端合并提示词与验收约束.md` 合并正式前端。
5. 使用主项目真实登录、账号、角色和客户归属完成联调。
6. 执行桌面端、移动端、权限、降级和完整业务链路验收。
7. 主项目稳定后再实施对话历史、Redis 缓存和长期留存。
From 4f495c6048bf219ff37e230259b244092b076ea6 Mon Sep 17 00:00:00 2001
From: zhangshy <994452054@qq.com>
Date: Sat, 12 Sep 2026 16:58:31 +0800
Subject: [PATCH 9/9] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=A3=8E=E6=8E=A7?=
=?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=96=87=E6=A1=A3=E9=87=8D=E5=91=BD=E5=90=8D?=
=?UTF-8?q?=E5=90=8E=E7=9A=84=E5=BC=95=E7=94=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/40-前端验收清单.md | 2 +-
tools/portal.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/40-前端验收清单.md b/docs/40-前端验收清单.md
index b69b408..eb6a33e 100644
--- a/docs/40-前端验收清单.md
+++ b/docs/40-前端验收清单.md
@@ -72,7 +72,7 @@ D:\conda\envs\jr_py313\python.exe tools\portal.py --base-url http://127.0.0.1:80
## 3. 员工 · 风控工作台(`risk_t`)
-> 本节按 `docs/风控业务演示文档/17-前端合并提示词与验收约束.md` 重写过,功能清单与交互约束都对齐了那份文档。
+> 本节按 `docs/风控业务演示文档/17-风控模块-前端合并提示词与验收约束.md` 重写过,功能清单与交互约束都对齐了那份文档。
| # | 操作 | 预期结果 |
|---|---|---|
diff --git a/tools/portal.py b/tools/portal.py
index 4486431..8a374a4 100644
--- a/tools/portal.py
+++ b/tools/portal.py
@@ -908,7 +908,7 @@ function showExtra(title, r) {
}
/* ---------------- 员工 · 风控工作台 ----------------
- 按 docs/风控业务演示文档/17-前端合并提示词与验收约束.md 实现:
+ 按 docs/风控业务演示文档/17-风控模块-前端合并提示词与验收约束.md 实现:
概览五指标、预警队列每页 5 条 + 风险等级优先 + 筛选 + 游标分页 + 详情弹窗、
五个处置动作(确认接收二次确认、误报必填理由)、八类证据、通知、日报流式。
交互上不用原生 alert/prompt —— 改用 Toast 与模态框。 */