fix(portal): 补齐写操作的必填 body(风控升级/解决、场外六个写接口)
- 风控: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 更新为实测结果并列出各写接口的必填字段表
This commit is contained in:
+22
-9
@@ -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),均"待处理" ✅实测<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-5 | 点「生成日报」 | HTTP 200,返回日报内容 ⚠️按契约 |
|
||||
| 3-6 | 对某条预警点「确认」 | 二次确认后返回业务结果。✅实测:`POST .../acknowledgements` → **409「只有待处理的预警才能确认解决」** —— 该动作**有状态前置条件**,不是任意状态都能点,属正常业务约束(页面会原样显示原因) |
|
||||
| 3-7 | 点「升级」/「解决」 | 同上,各自有状态约束;状态不符时返回 409 ⚠️按契约 |
|
||||
| 3-6 | 对某条预警点「确认」 | 二次确认后返回业务结果。✅实测:`POST .../acknowledgements`(**无必填 body**)→ **409「只有待处理的预警才能确认解决」** —— 该动作**有状态前置条件**,不是任意状态都能点 |
|
||||
| 3-7 | 点「升级」/「解决」 | ✅实测:**必须先「确认」接收预警**,否则两者都返回 **409「请先确认接收预警」**。<br>升级要填 <code>reason</code>、解决要填 <code>resolution</code>(**字段名不同**,各 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`),
|
||||
|
||||
+58
-17
@@ -724,7 +724,9 @@ function renderStaff(box) {
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>预警列表</h2>
|
||||
<p class="hint">点「处置」可执行确认 / 升级 / 解决 —— 这些是真实写操作,会在审计里留痕。</p>
|
||||
<p class="hint">操作有<b>业务顺序</b>:必须先点「确认」接收预警,才能「升级」或「解决」——
|
||||
顺序不对会返回 <code>409 请先确认接收预警</code>。
|
||||
这三个都是真实写操作,会在审计留痕;升级要填原因、解决要填处理结论(各 1-500 字)。</p>
|
||||
<div id="alerts"><div class="muted">加载中…</div></div>
|
||||
</div>
|
||||
<div class="panel" id="staff-extra"></div>`;
|
||||
@@ -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) {
|
||||
<div class="panel">
|
||||
<h2>运营工作台 · 场外基金</h2>
|
||||
<p class="hint">面向 <code>operator</code>。场外线的服务层用**角色门槛**
|
||||
<code>{"operator","risk_operator","admin","super_admin"}</code> 判断,所以主体功能靠角色就通;
|
||||
另外给了 <code>financial:nl2sql:read</code>,用于单据字段识别。</p>
|
||||
<code>{"operator","risk_operator","admin","super_admin"}</code> 判断,所以主体功能靠角色就通。
|
||||
另外:场外的**写接口必须带 <code>operator_id</code>**,而且是**防伪校验** —— 平台会核对
|
||||
它是否等于当前登录用户,所以门户一律自动带本次登录的 user_id,不让你手填。</p>
|
||||
<div class="grid" id="mailbox"><div class="stat"><div class="n">…</div><div class="l">邮箱状态加载中</div></div></div>
|
||||
<div class="row" style="margin-top:14px">
|
||||
<button class="act primary" onclick="loadMailbox()">刷新邮箱状态</button>
|
||||
@@ -868,19 +883,25 @@ async function loadMails() {
|
||||
: `<div class="muted">HTTP ${r.status}${r.status === 403 ? ' —— 权限不足' : ''}</div><pre>${esc(pretty(r.body))}</pre>`;
|
||||
}
|
||||
|
||||
// 场外线的写接口**必须带 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 = `<h2>${esc(title)}</h2>
|
||||
|
||||
Reference in New Issue
Block a user