From 2525fbdad9b3d3fc798cd1bfb8daec7e0660016b 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 15:45:09 +0800
Subject: [PATCH 1/2] =?UTF-8?q?feat(portal):=20=E6=8E=A8=E5=B9=BF=E6=9D=90?=
=?UTF-8?q?=E6=96=99=E8=A1=A5=E6=88=90=E5=AE=8C=E6=95=B4=E6=B5=81=E7=A8=8B?=
=?UTF-8?q?=E5=90=91=E5=AF=BC=EF=BC=8C=E5=B9=B6=E6=94=B9=E4=B8=BA=E6=8C=89?=
=?UTF-8?q?=20body.code=20=E5=88=A4=E6=88=90=E8=B4=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 投顾视图:创建 -> 补结构化输入(含七项费率) -> 生成 -> 投递 -> 查询
生成后自动把 material_version_id 填进投递框
- 管理员视图:新增推广材料审核(promotion:review 只给管理员,职责分离)
- 统一结果渲染改看 body.code:本平台业务失败也返回 HTTP 200
(生成失败是 HTTP 200 + code=422 + '材料内容未通过合规校验')
- 输入骨架刻意不预填业绩(performance_info 有 show_* 开关可关),
费率填占位文本以便跑通流程;真实材料必须换成真实值
---
tools/portal.py | 225 ++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 206 insertions(+), 19 deletions(-)
diff --git a/tools/portal.py b/tools/portal.py
index 422d689..3006b54 100644
--- a/tools/portal.py
+++ b/tools/portal.py
@@ -939,6 +939,26 @@ function renderAdmin(box) {
知识库
+
+
+
推广材料审核
+
审核需要 promotion:review,只给管理员 —— 投顾没有这个码(职责分离),
+ 所以"生成"之后必须由管理员在这里通过,投顾才能投递。
+ 版本号从哪来:投顾执行「生成」后,响应里的 material_version_id 就是它
+ (状态 pending_review)。注意本平台失败时也返回 HTTP 200,
+ 判断成败要看 body.code。
+
+
+
+
+
+
+
+
+
+
+
+
`;
loadRoles();
loadAudit();
@@ -1021,6 +1041,31 @@ async function loadKnowledge() {
: `HTTP ${r.status}
${esc(pretty(r.body))}`;
}
+async function admPromoLookup() {
+ const t = $('promo-task').value.trim();
+ if (!t) return alert('请填任务单号');
+ 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;
+ $('promo-admin-extra').innerHTML = `任务 ${esc(t)}
+ HTTP ${r.status}(管理员不受"已投递"限制,能直接看到版本)
+ ${esc(pretty(r.body))}`;
+}
+
+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(可先点「查任务」自动填)');
+ const comment = $('adm-comment').value.trim() || null;
+ if (!confirm(`对 ${t} 的版本 ${vid} 执行「${decision}」?`)) return;
+ const r = await jpost('/api/call', { method:'POST',
+ path:`/api/v1/fund-promotion-materials/${t}/reviews`,
+ body:{ material_version_id: vid, decision, comment } });
+ $('promo-admin-extra').innerHTML = `审核结果(${esc(decision)})
+ HTTP ${r.status}
${esc(pretty(r.body))}`;
+}
+
/* ---------------- 投顾 ---------------- */
function renderAdvisor(box) {
box.innerHTML = `
@@ -1044,22 +1089,83 @@ function renderAdvisor(box) {
`;
goal();
+ promoSkeleton();
+}
+
+function promoSkeleton() {
+ // 两个刻意的选择:
+ // 1) 不预填任何业绩数字 —— `performance_info` 的业绩字段全部可选且带
+ // `show_product_performance` 开关,关掉即可;编造业绩是合规红线。
+ // 2) 费率七项**必须非空**:合规规则 `fee_structure.incomplete`(severity=block)
+ // 会拦下生成,任务会被置成 `compliance_failed`。这里填占位文本让它先跑通流程,
+ // 真实材料必须换成真实费率 —— 页面上也这么提示。
+ $('promo-inputs').value = JSON.stringify({
+ product_info: {
+ fund_type: "混合型",
+ operation_mode: "契约型开放式",
+ investment_objective: "在严格控制风险的前提下,力争实现基金资产的长期稳健增值",
+ benchmark: "沪深300指数收益率×60%+中债综合指数收益率×40%",
+ risk_level: "R3"
+ },
+ manager_info: {
+ management_company: "南方科技基金管理有限公司",
+ manager_name: "待填写",
+ registration_code: "待填写"
+ },
+ team_info: { team_description: "待填写" },
+ strategy_info: {
+ investment_scope: "本基金投资于国内依法发行上市的股票、债券等金融工具",
+ strategy: "自上而下的大类资产配置与自下而上的个股精选相结合",
+ restrictions: "不投资于法律法规禁止的标的"
+ },
+ fee_structure: {
+ subscription_fee: "待填写", purchase_fee: "待填写", redemption_fee: "待填写",
+ sales_service_fee: "待填写", management_fee: "待填写", custody_fee: "待填写",
+ client_maintenance_fee: "待填写"
+ },
+ performance_info: { show_product_performance: false, show_manager_performance: false },
+ risk_disclosure: { special_risks: ["市场风险", "流动性风险"], additional_notes: "详见基金合同与招募说明书" },
+ source_notes: {}
+ }, null, 2);
+}
+
+function promoTaskNo() {
+ const t = $('promo-task').value.trim();
+ if (!t) { alert('请先填任务单号(创建任务后会返回)'); return null; }
+ return t;
}
async function promoCreate() {
@@ -1067,16 +1173,75 @@ async function promoCreate() {
const title = $('promo-title').value.trim();
const style = $('promo-style').value.trim();
if (!name || !title || !style) return alert('产品名、材料标题、风格代码都要填');
- if (!confirm(`创建推广材料任务?\n产品:${name}\n标题:${title}\n风格:${style}`)) return;
- showAdv('创建推广材料', await jpost('/api/call', {
- method:'POST', path:'/api/v1/fund-promotion-materials',
- body:{ product_name: name, material_title: title, style_code: style } }));
+ 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 || {};
+ if (d.task_no) $('promo-task').value = d.task_no;
+ showAdv('① 创建任务' + (d.task_no ? ':' + d.task_no : ''), r);
+}
+
+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); }
+ const r = await jpost('/api/call', { method:'PUT',
+ path:`/api/v1/fund-promotion-materials/${t}/inputs`, body });
+ showAdv('② 保存结构化输入 ' + t, r);
+}
+
+async function promoGenerate(fmt) {
+ const t = promoTaskNo(); if (!t) return;
+ if (!confirm(`生成 ${fmt.toUpperCase()}?这一步会产出材料版本。`)) return;
+ const r = await jpost('/api/call', { method:'POST',
+ path:`/api/v1/fund-promotion-materials/${t}/generations`, body:{ output_formats:[fmt] } });
+ const body = r.body || {};
+ const d = body.data || {};
+ if (d.material_version_id) $('promo-version').value = d.material_version_id;
+ showAdv('③ 生成 ' + fmt.toUpperCase() + ' ' + t, r);
+ if (d.material_version_id) {
+ $('advisor-extra').insertAdjacentHTML('beforeend',
+ `已产出 material_version_id=${d.material_version_id},
+ 状态 pending_review(所以此刻查询任务看不到它 —— get_task 只返回 approved/sent)。
+ 下一步请管理员在「权限管理」页对它审核通过,之后你才能投递;版本号已自动填进上面的投递框。
`);
+ } else if (body.code) {
+ $('advisor-extra').insertAdjacentHTML('beforeend',
+ `生成未成功(body.code=${esc(body.code)})。最常见的原因是
+ 费率七项没填全:合规规则 fee_structure.incomplete 会把任务置成
+ compliance_failed。另注意:这个平台失败时也返回 HTTP 200,要看 body.code。
`);
+ }
+}
+
+async function promoDeliver() {
+ const t = promoTaskNo(); if (!t) return;
+ const versionId = parseInt($('promo-version').value.trim(), 10);
+ if (!versionId) return alert('请填 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 (!confirm(`把版本 ${versionId} 投递给投顾 ${ids.join(', ')}?`)) return;
+ const r = await jpost('/api/call', { method:'POST',
+ path:`/api/v1/fund-promotion-materials/${t}/deliveries`,
+ body:{ material_version_id: versionId, advisor_ids: ids, delivery_channel:'internal_record' } });
+ showAdv('⑤ 投递 ' + t, r);
}
async function promoGet() {
- const no = $('promo-no').value.trim();
- if (!no) return alert('请先填单号');
- showAdv('推广材料 ' + no, await GET(`/api/v1/fund-promotion-materials/${no}`));
+ const t = promoTaskNo(); if (!t) return;
+ const r = await GET(`/api/v1/fund-promotion-materials/${t}`);
+ const err = ((r.body || {}).error) || {};
+ let extra = '';
+ if (r.status === 404 && String(err.message || '').includes('尚未发送')) {
+ extra = ' —— 材料存在但还没投递给你:请确认已完成「补输入 → 生成 → 管理员审核 → 投递」。'
+ + '版本号可以在下面的原始返回里找,或让管理员查这个任务。';
+ }
+ showAdv('查询任务 ' + t, r);
+ if (extra) $('advisor-extra').insertAdjacentHTML('beforeend', `${esc(extra)}
`);
+}
+
+async function promoChecks() {
+ const t = promoTaskNo(); if (!t) return;
+ showAdv('合规检查 ' + t,
+ await GET(`/api/v1/fund-promotion-materials/${t}/compliance-checks`));
}
async function portfolioAnalysis() {
@@ -1092,10 +1257,32 @@ async function assetAllocation() {
}
function showAdv(title, r) {
- $('advisor-extra').innerHTML = `${esc(title)}
- HTTP ${r.status}${r.status === 403 ? ' —— 权限不足' : ''}
- ${r.status === 422 ? ' —— 报文格式不对(看下面 error 的字段提示)' : ''}
- ${esc(pretty(r.body))}`;
+ $('advisor-extra').innerHTML = result(title, r);
+}
+
+// 统一的结果渲染:**必须看 body.code**,不能只看 HTTP 状态。
+// 这个平台把业务失败也放在 HTTP 200 里(例如生成材料失败是
+// `HTTP 200 + body.code=422 + message=材料内容未通过合规校验`),
+// 只看 HTTP 会把它显示成成功。
+function result(title, r) {
+ const body = (r && r.body) || {};
+ 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 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) {
+ if (r.status === 403) extra = '当前角色权限不足(平台按设计 fail closed)';
+ else if (r.status === 404) extra = '资源不存在或不可见(见下方 message)';
+ }
+ return `${esc(title)}
+ ${bits.join(' · ')}${extra ? ' —— ' + extra : ''}
+ ${esc(pretty(body))}`;
}
async function goal() {
From 49ebda4cb7677a433d3eb4826cef069f81947fad 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 15:48:31 +0800
Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E5=89=8D?=
=?UTF-8?q?=E7=AB=AF=E9=AA=8C=E6=94=B6=E6=B8=85=E5=8D=95=EF=BC=8864=20?=
=?UTF-8?q?=E9=A1=B9=EF=BC=8C=E9=80=90=E9=A1=B9=E7=BB=99=E5=87=BA=E9=A2=84?=
=?UTF-8?q?=E6=9C=9F=E7=BB=93=E6=9E=9C=EF=BC=8C=E5=8C=BA=E5=88=86=E5=AE=9E?=
=?UTF-8?q?=E6=B5=8B=E4=B8=8E=E6=8C=89=E5=A5=91=E7=BA=A6=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/40-前端验收清单.md | 218 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 218 insertions(+)
create mode 100644 docs/40-前端验收清单.md
diff --git a/docs/40-前端验收清单.md b/docs/40-前端验收清单.md
new file mode 100644
index 0000000..3b05289
--- /dev/null
+++ b/docs/40-前端验收清单.md
@@ -0,0 +1,218 @@
+# 前端验收清单(统一登录门户)
+
+> **目的**:把门户的**每一个功能**都走一遍,逐项对照"预期结果"判断是否符合预期。
+> **被测对象**:`tools/portal.py`(统一登录门户),默认
+> **时点**:2026-09-12
+> **证据口径**:标 ✅实测 的项是本轮真实跑过并确认的;标 ⚠️按契约 的项是照接口定义推断的
+> (沙箱里不便反复写业务数据,留给你点的时候确认)。
+
+---
+
+## 0. 启动与前置
+
+### 0.1 前置(不满足会直接显示原因,不会静默)
+
+| # | 项 | 命令 / 检查 | 预期 |
+|---|---|---|---|
+| 0-1 | 依赖服务 | MySQL、Redis 可用(Docker Desktop 要在跑,Milvus 才可用) | 门户顶部不出现"平台初始化失败"红条 |
+| 0-2 | 演示账号 | `python tools/seed_test_rbac.py` 然后 `python tools/set_user_password.py` | 五个账号都能登录;**口令脚本非幂等**,重复执行等于重设密码 |
+| 0-3 | 停常驻 Worker | 确认没有 `python -m app.worker` 在跑 | 否则客服对话会被抢队列,页面一直转圈 |
+
+### 0.2 启动
+
+```powershell
+D:\conda\envs\jr_py313\python.exe tools\portal.py # 进程内直挂平台,走真实鉴权栈
+D:\conda\envs\jr_py313\python.exe tools\portal.py --base-url http://127.0.0.1:8000
+```
+
+- [ ] 打开 → **预期**:出现登录卡片,标题"基金智能服务平台"
+- [ ] 页面顶部右侧显示连接环境 → **预期**:`进程内 · 127.0.0.1:3306/jr`(口令已脱敏成 `***`)
+- [ ] 用**错的密码**登录 → **预期**:红条提示 `登录失败(HTTP 200):...`,停在本页 ✅实测
+- [ ] 同一个浏览器开**两个标签页**,分别登录客户与管理员 → **预期**:互不干扰(会话号存 `sessionStorage`)
+
+---
+
+## 1. 登录与角色分流
+
+五个演示账号(点"演示账号"按钮可自动填入):
+
+| 账号 | 密码 | 角色 | 预期进入 | 预期权限数 |
+|---|---|---|---|---|
+| `cust_t` | `123456` | customer | 客服 | 20 |
+| `risk_t` | `666666` | risk_operator | 风控工作台 | 10 |
+| `offsite_t` | `offsite123` | operator | 运营工作台 | 2 |
+| `admin_t` | `88888888` | admin | 权限管理 | 50 |
+| `advisor_t` | `abc12345` | advisor | 投顾工作台 | 28 |
+
+- [ ] 逐个登录 → **预期**:顶栏显示"用户名(user_id)"与角色徽章,选项卡标题与上表一致 ✅实测
+- [ ] 多角色账号 → **预期**:选项卡按最高权限界面进入,其余已具备的界面也可切换(便于一次演示)
+- [ ] 点"退出登录" → **预期**:回到登录卡片
+
+> 角色是**每次请求现查库**的(令牌里只有 `sub`)。改了库里角色,**重新登录**即生效,不用重启门户。
+
+---
+
+## 2. 客户 · 客服视图(`cust_t`)
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 2-1 | 输入"赎回基金多久到账?"发送 | 气泡里出现回答(如"货币基金T+0或T+1到账…QDII T+7、T+10"),下方标签显示**意图**(如 `faq`) ✅实测 |
+| 2-2 | 看回答末尾 | 附**合规提示**:"本内容仅为投资风险参考,不构成任何直接投资建议…" ✅实测 |
+| 2-3 | 问一个答不了的(如"帮我下单") | Agent 引导拨打客服热线;若判定需人工,标签显示**已转人工** ✅实测(问"赎回费怎么算"得到引导话术) |
+| 2-4 | 点「我的画像」 | 返回当前客户的记忆画像(HTTP 200) ✅实测 |
+| 2-5 | 点「我的画像候选」 | 返回候选列表;**没有候选时**显示"暂无候选(HTTP 200)"而不是报错 ⚠️按契约 |
+| 2-6 | 候选里点「确认」/「拒绝」 | HTTP 200,状态变更;再刷新列表状态已更新 ⚠️按契约(本机暂无候选数据) |
+| 2-7 | 点「转人工」 | 弹出说明输入框 → 确认后 **HTTP 202**,返回 `handover_id` 与 `status=pending` ✅实测 |
+| 2-8 | 转人工后,用管理员看"客服转人工工单" | 新工单出现在队列里 ✅实测(`ticket-d8c526a9…`) |
+
+> **2-7 的实现细节**:门户会先调 `POST /api/v1/conversations` 真实建会话(**201**)再转人工。
+> 早期版本用自己编的 session id,会得到 404「会话不存在」。
+
+---
+
+## 3. 员工 · 风控工作台(`risk_t`)
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 3-1 | 进入即自动加载"总览" | 数字卡片;数据范围 `all`(能看全部客户的预警) ✅实测 |
+| 3-2 | 点「刷新总览」 | 同上,HTTP 200 ✅实测 |
+| 3-3 | 点「拉取预警列表」→ 看表格 | 预警号 / 客户 / 规则 / 等级 / 状态 / 操作;空库时提示"可先点触发一次扫描" ✅实测 |
+| 3-4 | 点「触发一次扫描」 | **HTTP 200,`body.code=0`**(这条以前会因缺幂等头报 422,已修) ✅实测 |
+| 3-5 | 点「生成日报」 | HTTP 200,返回日报内容 ⚠️按契约 |
+| 3-6 | 对某条预警点「确认」 | 二次确认后 HTTP 200,状态变为已确认;**审计里会留痕** ⚠️按契约(写操作) |
+| 3-7 | 点「升级」/「解决」 | 同上,各自状态流转 ⚠️按契约 |
+| 3-8 | 点某个默认不存在的预警号 | 404,提示资源不存在 —— 属正常 fail closed ⚠️按契约 |
+| 3-9 | 用**客户**账号访问风控接口 | **403 缺少操作权限**(`risk_t` 能看,`cust_t` 不能) ✅实测(客户访问 `/admin/roles` 为 403) |
+
+> ⚠️ **注意**:`risk_t` 有 `audit:read`,所以它访问 `/api/v1/admin/roles` 是 **200 而不是 403** ——
+> 这是种子设计如此,不是越权漏洞。
+
+---
+
+## 4. 员工 · 运营工作台(`offsite_t`)
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 4-1 | 进入即加载"邮箱状态" | 数字卡片,HTTP 200 ✅实测 |
+| 4-2 | 点「拉取邮件列表」 | 表格(邮件 ID / 主题 / 状态 / 操作);**邮箱未配置时**返回空列表或错误说明,不是白屏 ✅实测(HTTP 200) |
+| 4-3 | 点某封邮件的「识别字段」 | 返回识别结果 JSON ⚠️按契约(需库里有邮件数据) |
+| 4-4 | 点「删除」邮件 | 二次确认后写操作;审计留痕 ⚠️按契约 |
+| 4-5 | 点「触发邮箱恢复」 | 二次确认后 HTTP 200 ⚠️按契约 |
+| 4-6 | 在"单据处理"填一个**真实存在**的 task_id,点「识别字段」「规则结果」 | 返回该单据的字段与规则判定 ⚠️按契约 |
+| 4-7 | 填一个**不存在**的 task_id | 404 资源不存在,页面上原样显示 —— 正常 ⚠️按契约 |
+| 4-8 | 点「确认单据」/「重试识别」/「创建通知」 | 二次确认后写操作;审计留痕 ⚠️按契约 |
+| 4-9 | 点「重算结算统计」 | 二次确认后 HTTP 200 ⚠️按契约 |
+
+> **运营为什么只有 2 项权限却能用**:场外线的服务层用的是**角色门槛**
+> `{"operator","risk_operator","admin","super_admin"}`(`offsite_fund_service.py:2600`),
+> 不是权限码。那 2 项是 `offsite:write` 和 `financial:nl2sql:read`。
+> 换句话说:**"看不到运营界面"以前是前端没做,不是权限问题**。
+
+---
+
+## 5. 管理员 · 权限管理(`admin_t`)
+
+### 5.1 角色与权限
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 5-1 | 进入即加载角色卡片 | 每个角色显示 **权限数 / 角色名 / user_count** ✅实测(`GET /admin/roles` 200) |
+| 5-2 | 点任一角色卡片 | 列出该角色的**权限码**(药丸标签)+ 角色详情 ✅实测(customer 20 项) |
+| 5-3 | 对照第 1 节的权限数 | 与登录时顶栏显示的权限数一致 |
+| 5-4 | 输入 `9001` 点「查询该用户的角色」 | 返回 `cust_t → customer` 的解析结果 ✅实测 |
+
+> **平台只提供只读查询**:改权限要发布新的 `config_release`,**没有直接写接口** ——
+> 这是设计(配置受版本控制),不是功能没做完。页面上也这么写了。
+
+### 5.2 审计与工单
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 5-5 | 点「刷新」审计流水 | 时间 / 动作 / 操作者 / 结果;做过写操作后能看到刚才那条 ✅实测(200) |
+| 5-6 | 点「刷新工单」 | 客户在第 2-7 步建的工单出现在这里(工单号 / 来源 / 优先级 / 原因 / 状态)✅实测 |
+| 5-7 | 点「列出知识文档」 | 文档清单(ID / 标题 / 状态)⚠️按契约(需 KB 有数据) |
+
+### 5.3 推广材料审核
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 5-8 | 填投顾给你的任务单号,点「查任务」 | 返回任务详情,**若已有 `approved/sent` 版本会自动填进版本号框** ✅实测 |
+| 5-9 | 填 `material_version_id`,点「通过」 | 二次确认后 HTTP 200 `code=0`;此后投顾才能投递 ✅实测(版本 19 通过) |
+| 5-10 | 点「退回修改」/「拒绝」 | 同样 200,状态流转 ⚠️按契约 |
+
+---
+
+## 6. 投顾 · 投顾工作台(`advisor_t`)
+
+### 6.1 投资目标与方案
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 6-1 | 客户 ID 填 `9001`,点「查投资目标」 | **200**,返回目标(目标区间、基准)—— 前提是 9001 在你名下且已建过目标 ✅实测(`4.5000 - 8.0000`、沪深300) |
+| 6-2 | 改填一个**不在你名下**的客户 ID | **404「客户不可访问」** —— 最小权限,`data_scope=own_customers` 生效 ✅实测设计如此 |
+| 6-3 | 点「已发布方案」 | 200,返回已发布方案列表 ✅实测 |
+| 6-4 | 点「跑组合分析」 | 二次确认后 200(请求体是**空对象** `{}`,多传字段会 422)✅实测 |
+| 6-5 | 点「生成资产配置」 | 同上 ✅实测 |
+
+### 6.2 推广材料(**六步流程,顺序不能跳**)
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 6-6 | ① 点「创建任务」 | 200,返回 `task_no`(如 `PM-20260912-0007`)并**自动填进单号框** ✅实测 |
+| 6-7 | ② 改一下结构化输入(**六个块必填**),点「保存输入」 | 200 `code=0` ✅实测 |
+| 6-8 | ③ 点「生成 PPTX」 | 200 `code=0`,返回 **`material_version_id`** 与 `status=pending_review`,**版本号自动填进投递框** ✅实测(真实产出 `v1.pptx`) |
+| 6-9 | 若**没填费率**就点生成 | `body.code=422`「材料内容未通过合规校验」,`findings` 里是 `fee_structure.incomplete`(severity=**block**),任务被置为 `compliance_failed` ✅实测 |
+| 6-10 | ④ 让管理员在 5.3 审核通过 | 见 5-9 |
+| 6-11 | ⑤ 点「投递」(投顾 id 填 `9020`) | 200 `code=0` ✅实测 |
+| 6-12 | ⑥ 点「查询任务」 | **200**,`status="sent"`,含 `material_version`(版本号、pptx 路径)✅实测 |
+| 6-13 | **没投递就查询** | **404「该材料尚未发送给当前投顾」** —— 这是**合规设计**,不是故障;页面会追加提示告诉你怎么走完 ✅实测 |
+| 6-14 | 点「合规检查结果」 | 列出 findings(通过的会显示 `overall.pass`)✅实测 |
+
+> ⚠️ **两条必须知道的约定**:
+> 1. **费率七项必须非空**(`subscription_fee`/`purchase_fee`/`redemption_fee`/`sales_service_fee`/
+> `management_fee`/`custody_fee`/`client_maintenance_fee`),否则合规规则**阻断**生成。
+> 骨架里填的是「待填写」占位,**真实材料必须换成真实费率**。
+> 2. 骨架**刻意不预填任何业绩数字**:`performance_info` 的业绩字段全部可选且带
+> `show_product_performance` 开关,关掉即可 —— 编造业绩是红线。
+
+---
+
+## 7. 通用行为预期(跨视图)
+
+| # | 情形 | 预期表现 |
+|---|---|---|
+| 7-1 | **业务失败也返回 HTTP 200** | 本平台把业务错误放在 `body.code`(如生成失败 `HTTP 200 + code=422`)。门户按 **body.code** 判成败并标红 ✅实测 |
+| 7-2 | 403 | "当前角色权限不足(平台按设计 fail closed)",原样显示不隐藏 ✅实测 |
+| 7-3 | 404 | 显示 message。注意 `SESSION_NOT_FOUND` 被**三个异常类共用**,可能是"会话不存在"、"客户不可访问"或"知识文档不存在",只能看 message 区分 ✅实测 |
+| 7-4 | 422 | 报文格式问题,`error.field_errors` 会指出具体字段 ✅实测 |
+| 7-5 | 写操作 | 一律二次确认(`confirm`),避免误点改数据 ✅实测 |
+| 7-6 | 令牌 | 只存在服务端,浏览器拿不到;前端只有一个随机会话号 ✅设计 |
+| 7-7 | 空数据 | 显示"暂无…(HTTP xxx)"或原始返回,不白屏 ✅实测 |
+
+---
+
+## 8. 已知限制(先说明,免得当成 bug)
+
+1. **权限界面只读** —— 平台没有写接口,改权限走 `config_release` 发布;
+2. **运营工作台的"单据处理"需要一个真实的 task_id** —— 本机没有场外单据数据时,识别/确认/通知只能看到 404,属正常;
+3. **组合分析与资产配置只对空请求体有效**(`{}`,`additionalProperties:false`);
+4. **风控日报/处置类写操作**会在库里留数据与审计,验收时建议用专用库或事后清理;
+5. **门户是单进程工具**:会话存内存,重启门户需要重新登录。
+
+---
+
+## 9. 验收记录表
+
+| 章节 | 项数 | 通过 | 不符合预期 | 备注 |
+|---|---:|---:|---:|---|
+| 0 启动与前置 | 4 | | | |
+| 1 登录与分流 | 3 | | | |
+| 2 客户 · 客服 | 8 | | | |
+| 3 员工 · 风控 | 9 | | | |
+| 4 员工 · 运营 | 9 | | | |
+| 5 管理员 · 权限 | 10 | | | |
+| 6 投顾 · 投顾台 | 14 | | | |
+| 7 通用行为 | 7 | | | |
+| **合计** | **64** | | | |
+
+> 发现不符合预期的项,记下**章节号 + 当时的 `trace_id`**(响应里带),可以直接定位到那一次请求。