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 1/4] =?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 2/4] =?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 3/4] =?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 4/4] =?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="统一登录门户(按角色分流)")