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。

+
…
加载中
- - - + + + + + +
+
-

预警列表

-

操作有业务顺序:必须先点「确认」接收预警,才能「升级」或「解决」—— - 顺序不对会返回 409 请先确认接收预警。 - 这三个都是真实写操作,会在审计留痕;升级要填原因、解决要填处理结论(各 1-500 字)。

+

预警队列

+

固定每页 ${RISK_PAGE_SIZE} 条、按风险等级优先。**排序在页内进行** —— + 接口本身不支持排序参数,跨页整体排序需要服务端支持。
+ ⚠️ 风险等级筛选值必须用 高 / 中 / 低:预警对象的 risk_level 是「高」, + 而概览的 levels 键是「高风险」—— 平台两处口径不一致,用「高风险」筛不出任何结果。

+
+ + + + + + + + + + +
加载中…
+
+ + + +
`; 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]) => `
${esc(v)}
${esc(k)}
`).join('') - : `
${r.status}
总览返回(详见下方)
`; - 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 = `
—
${esc(verdict.reason)}
`; 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 ` - - - - - - `; - }).join('') - + '
预警号客户等级规则状态操作
${no}${esc(a.customer_name || a.customer_no || a.customer_id || '')}${esc(a.risk_level ?? a.level ?? '')}${esc(rules)}${esc(a.status ?? '')} - - - -
'; + 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 ` + + + + + + + + `; + }).join('') + + '
预警号客户等级规则处置状态回执摘要操作
${no}${esc(a.customer_name || a.customer_no || '')}${lv}${esc(rules)}${esc(a.status || '')}${esc(ack)} ${escMark}${esc(a.evidence_summary || '')} + + +
'; + } + $('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) => ``).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 ``; + }).join('') + '').join('') + + '
${esc(c)}
${esc(text)}
'; +} + +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) => ` + + + `).join('') + + '
预警编号类型发送状态时间
${esc(n.alert_no ?? '')}${esc(n.notification_type ?? n.type ?? '')}${esc(n.send_status ?? n.status ?? '')}${esc(n.created_at ?? n.sent_at ?? '')}
' + : '
暂无通知记录。
'; } /* ---------------- 员工 · 运营工作台(场外基金) ---------------- */ @@ -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 与模态框。 */