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:
2026-09-12 16:08:38 +08:00
parent 552055ee2d
commit 76923d7e8b
2 changed files with 80 additions and 26 deletions
+58 -17
View File
@@ -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>