fix(portal): 修风控预警列表 422(limit 上限是 5,改为不传);运营邮件改用 page/page_size;审计 limit 钳制到 1-100

- risk/alerts 传 limit=20 会 422 query.limit<=5,导致整张表格渲染不出来、
  行内确认/升级/解决按钮随之消失 —— 这正是验收清单 3-3/3-6~3-9 看不到功能的原因
- 实测列表返回 2 条预警;处置接口有状态前置条件(409 = 当前状态不允许)
- 清单 3-3/3-6 更新为实测结果并写明 limit 上限与状态机约束
This commit is contained in:
2026-09-12 16:03:22 +08:00
parent 49ebda4cb7
commit 552055ee2d
2 changed files with 41 additions and 21 deletions
+8 -4
View File
@@ -76,16 +76,20 @@ D:\conda\envs\jr_py313\python.exe tools\portal.py --base-url http://127.0.0.1:80
|---|---|---| |---|---|---|
| 3-1 | 进入即自动加载"总览" | 数字卡片;数据范围 `all`(能看全部客户的预警) ✅实测 | | 3-1 | 进入即自动加载"总览" | 数字卡片;数据范围 `all`(能看全部客户的预警) ✅实测 |
| 3-2 | 点「刷新总览」 | 同上,HTTP 200 ✅实测 | | 3-2 | 点「刷新总览」 | 同上,HTTP 200 ✅实测 |
| 3-3 | 点「拉取预警列表」→ 看表格 | 预警号 / 客户 / 规则 / 等级 / 状态 / 操作;空库时提示"可先点触发一次扫描" ✅实测 | | 3-3 | 进入即自动加载"预警列表" | **表格出现**:预警号 / 客户 / 等级 / 规则 / 状态 / 操作。本机实测 2 条:`ALDEMO0002`(高,RW-015/RW-003)、`ALDEMO0001`(中,RW-007/RW-002/RW-012),均"待处理" ✅实测<br>⚠️ **门户刻意不传 `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-4 | 点「触发一次扫描」 | **HTTP 200,`body.code=0`**(这条以前会因缺幂等头报 422,已修) ✅实测 |
| 3-5 | 点「生成日报」 | HTTP 200,返回日报内容 ⚠️按契约 | | 3-5 | 点「生成日报」 | HTTP 200,返回日报内容 ⚠️按契约 |
| 3-6 | 对某条预警点「确认」 | 二次确认后 HTTP 200,状态变为已确认;**审计里会留痕** ⚠️按契约(写操作) | | 3-6 | 对某条预警点「确认」 | 二次确认后返回业务结果。✅实测:`POST .../acknowledgements` → **409「只有待处理的预警才能确认解决」** —— 该动作**有状态前置条件**,不是任意状态都能点,属正常业务约束(页面会原样显示原因) |
| 3-7 | 点「升级」/「解决」 | 同上,各自状态流转 ⚠️按契约 | | 3-7 | 点「升级」/「解决」 | 同上,各自有状态约束;状态不符时返回 409 ⚠️按契约 |
| 3-8 | 点某个默认不存在的预警号 | 404,提示资源不存在 —— 属正常 fail closed ⚠️按契约 | | 3-8 | 点一个**不存在**的预警号 | 404 资源不存在 —— 正常 fail closed ⚠️按契约 |
| 3-9 | 用**客户**账号访问风控接口 | **403 缺少操作权限**(`risk_t` 能看,`cust_t` 不能) ✅实测(客户访问 `/admin/roles` 为 403) | | 3-9 | 用**客户**账号访问风控接口 | **403 缺少操作权限**(`risk_t` 能看,`cust_t` 不能) ✅实测(客户访问 `/admin/roles` 为 403) |
> ⚠️ **注意**:`risk_t` 有 `audit:read`,所以它访问 `/api/v1/admin/roles` 是 **200 而不是 403** —— > ⚠️ **注意**:`risk_t` 有 `audit:read`,所以它访问 `/api/v1/admin/roles` 是 **200 而不是 403** ——
> 这是种子设计如此,不是越权漏洞。 > 这是种子设计如此,不是越权漏洞。
>
> ⚠️ 行内三条按钮对应 `acknowledgements` / `escalations` / `resolutions` 三个端点,都是**写操作**:
> 会在库里留数据与审计,且**受状态机约束**。列表拿不到数据时这三个按钮不会出现 ——
> 先确认 3-3 是否正常。
--- ---
+33 -17
View File
@@ -744,25 +744,34 @@ async function loadRisk() {
} }
async function loadAlerts() { async function loadAlerts() {
const r = await GET('/api/v1/risk/alerts', { limit: 20 }); // 刻意**不传 limit**:该接口的 limit 上限是 5(传 20 会 422
const items = r.body?.data?.items || r.body?.data || []; // `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'); const box = $('alerts');
if (!Array.isArray(items) || !items.length) { if (!Array.isArray(items) || !items.length) {
box.innerHTML = `<div class="muted">没有预警数据(HTTP ${r.status})。可先点「触发一次扫描」。</div>`; box.innerHTML = `<div class="muted">没有预警数据(HTTP ${r.status}${r.body?.code ? ' code=' + r.body.code : ''})。可先点「触发一次扫描」。</div>
<pre>${esc(pretty(r.body))}</pre>`;
return; return;
} }
box.innerHTML = `<table><thead><tr><th>预警号</th><th>客户</th><th>规则</th><th>等级</th><th>状态</th><th>操作</th></tr></thead><tbody>` box.innerHTML = `<table><thead><tr><th>预警号</th><th>客户</th><th>等级</th><th>规则</th><th>状态</th><th>操作</th></tr></thead><tbody>`
+ items.map((a) => `<tr> + items.map((a) => {
<td><code>${esc(a.alert_no ?? a.alert_id)}</code></td> const no = esc(a.alert_no ?? a.alert_id ?? '');
<td>${esc(a.customer_id)}</td> const rules = Array.isArray(a.rule_codes) ? a.rule_codes.join(', ') : (a.rule_code ?? '');
<td>${esc(a.rule_code ?? a.rule ?? '')}</td> return `<tr>
<td>${esc(a.level ?? a.severity ?? '')}</td> <td><code>${no}</code></td>
<td>${esc(a.status)}</td> <td>${esc(a.customer_name || a.customer_no || a.customer_id || '')}</td>
<td>${esc(a.risk_level ?? a.level ?? '')}</td>
<td>${esc(rules)}</td>
<td>${esc(a.status ?? '')}</td>
<td> <td>
<button class="act" onclick="ack('${esc(a.alert_no ?? a.alert_id)}')">确认</button> <button class="act" onclick="ack('${no}')">确认</button>
<button class="act warn" onclick="esc_( '${esc(a.alert_no ?? a.alert_id)}')">升级</button> <button class="act warn" onclick="esc_('${no}')">升级</button>
<button class="act primary" onclick="resolve('${esc(a.alert_no ?? a.alert_id)}')">解决</button> <button class="act primary" onclick="resolve('${no}')">解决</button>
</td></tr>`).join('') </td></tr>`;
}).join('')
+ '</tbody></table>'; + '</tbody></table>';
} }
@@ -845,8 +854,10 @@ async function loadMailbox() {
} }
async function loadMails() { async function loadMails() {
const r = await GET('/api/v1/offsite-fund/mails', { limit: 20 }); // 该接口的分页参数是 `page` / `page_size`(不是 limit)。
const items = r.body?.data?.items || r.body?.data || []; const r = await GET('/api/v1/offsite-fund/mails', { page: 1, page_size: 20 });
const raw = r.body?.data;
const items = Array.isArray(raw) ? raw : (raw?.items || []);
$('mails').innerHTML = Array.isArray(items) && items.length $('mails').innerHTML = Array.isArray(items) && items.length
? `<table><thead><tr><th>邮件 ID</th><th>主题</th><th>状态</th><th>操作</th></tr></thead><tbody>` ? `<table><thead><tr><th>邮件 ID</th><th>主题</th><th>状态</th><th>操作</th></tr></thead><tbody>`
+ items.map((m) => `<tr><td><code>${esc(m.mail_id ?? m.id)}</code></td> + items.map((m) => `<tr><td><code>${esc(m.mail_id ?? m.id)}</code></td>
@@ -926,6 +937,7 @@ function renderAdmin(box) {
<h2>审计流水</h2> <h2>审计流水</h2>
<p class="hint">谁做了什么、有没有被拒,都记在这里(<code>GET /api/v1/admin/audit-records</code>)。</p> <p class="hint">谁做了什么、有没有被拒,都记在这里(<code>GET /api/v1/admin/audit-records</code>)。</p>
<div class="row"><input class="q" id="audit-limit" value="20" style="width:80px"> <div class="row"><input class="q" id="audit-limit" value="20" style="width:80px">
<span class="muted">1-100</span>
<button class="act" onclick="loadAudit()">刷新</button></div> <button class="act" onclick="loadAudit()">刷新</button></div>
<div id="audit"><div class="muted">加载中…</div></div> <div id="audit"><div class="muted">加载中…</div></div>
</div> </div>
@@ -1003,7 +1015,11 @@ async function userRoles() {
} }
async function loadAudit() { async function loadAudit() {
const limit = $('audit-limit').value.trim() || '20'; // 该接口 limit 的取值范围是 1-100;钳一下,免得填大值得到 422。
let limit = parseInt($('audit-limit').value.trim() || '20', 10);
if (!Number.isFinite(limit)) limit = 20;
limit = Math.min(100, Math.max(1, limit));
$('audit-limit').value = limit;
const r = await GET('/api/v1/admin/audit-records', { limit }); const r = await GET('/api/v1/admin/audit-records', { limit });
const items = r.body?.data?.items || r.body?.data || []; const items = r.body?.data?.items || r.body?.data || [];
const box = $('audit'); const box = $('audit');