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] =?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)}