投顾可自助审核/发布自己生成的推荐方案(原先只有管理员能推进草案)

## 现象与根因

投顾工作台生成推荐方案后,草案停在 `pending_review` 且投顾无法推进:

- 服务层 `review` / `publish` 都带 **`admin=True` 角色闸门**
  (`product_recommendation_service.py:286/317`),即使投顾角色**已经持有**
  `product-recommendation:review` / `:publish` 两个权限码也一律 403;
- 审核/发布端点只注册在 **admin 路由**下(`/api/v1/admin/advisor/...`),
  投顾侧根本没有对应入口;
- 投顾工作台也没有审核/发布按钮(`published-module.js` 原注释即写着
  "发布动作要求管理员,投顾侧只读")。

于是业务上"让投顾自己审核"完全做不到,必须切管理员账号。

## 修法(三处配套,安全边界保留)

1. `app/service/product_recommendation_service.py`
   - `review` / `publish` 去掉 `admin=True`,**只按权限码判定**
     (`product-recommendation:review` / `:publish`,目前仅 advisor 与 admin 持有);
   - `reviewer_user_id` 照旧如实落库,审计可追;
   - 注释写明:若要回到"四眼原则/管理员专属",把 `admin=True` 加回即可。
2. `app/api/controllers/recommendations.py`
   - 新增投顾侧路由 `POST /api/v1/advisor/recommendations/{id}/reviews`
     与 `.../publications`(与 admin 路由调用同一服务方法)。
3. 前端
   - `common/api-client.js`:注册 `ADVISOR_REVIEW_RECOMMENDATION` /
     `ADVISOR_PUBLISH_RECOMMENDATION`;
   - `employee-advisor/dashboard/actions-module.js`:结果区在拿到 `content_id` 后
     给出「审核通过 / 驳回 / 发布给客户」按钮(结果区是 `innerHTML` 重建的,
     所以每次渲染后重新绑定);审核通过后就地换成「发布给客户」;
   - `published-module.js`:监听 `advisor:published-refresh`,发布成功后列表自动刷新。

## 未放宽的部分(有意保留)

- **管理面复核队列** `GET /api/v1/admin/advisor/pending-contents` 仍为
  `admin=True` 专属 —— `tests/integration/test_advisor_review_queue_mysql.py`
  里"投顾读不到该队列"的断言**未改动**;
- 客户/风控/运营角色不持有这两个权限码,因此不受影响。

## 验证(真实 HTTP,9020 身份)

```
① 生成推荐方案(客户 9001)→ content_id=19, pending_review
② 投顾自助审核通过          → HTTP 200 status=approved     (改前 403)
③ 投顾自助发布              → HTTP 200 status=published
④ 已发布列表                → 含 id=19 ✅
```

新增回归测试 `test_advisor_can_review_and_publish_own_recommendation`
(客户缺测评/目标时 `pytest.skip` 并说明是数据前置,不误判为权限失败)。

## 门禁

- `pytest tests/unit tests/contract` → 1458 passed;
- `pytest tests/integration` → 111 passed + 1 例
  `test_worker_runtime_mysql::...repeat[False]` 失败,**经复跑确认是 AGENTS.md 记载的
  "常驻 Worker 抢队列",停掉常驻 Worker 后该用例 2 passed**,与本次改动无关;
- `ruff` 干净;三个 JS 文件 `node --check` 通过。
This commit is contained in:
2026-09-14 23:27:44 +08:00
parent 5da2fb5790
commit 467d2b5169
6 changed files with 237 additions and 20 deletions
+34 -10
View File
@@ -3,7 +3,16 @@ import { clearAuthSession, getAccessToken } from '/static/portal/common/auth.js?
const ENDPOINTS = Object.freeze({
// 健康检查没有 `data` 信封(是裸的 `{"status": ...}`),所以必须 `raw: true` ——
// 否则调用方拿到 `payload.data`(undefined),会把"后端在线"判成"离线"。
HEALTH: { method: 'GET', path: '/health', auth: false, raw: true },
//
// ⚠️ 路径必须是后端**真实存在**的路由。此前这里写的是 `/health`,而 `app/main.py`
// 只挂了 `/internal/health/live` 与 `/internal/health/ready`(无 `/health`)——
// 于是投顾工作台的探测恒返回 404,被 `.catch()` 判成"后端未连接 · 本地引擎",
// 即使后端完全正常也照显不误。
//
// 选 `live` 而不是 `ready`:前端这一句的语义是"**后端进程是否在线**"。
// `ready` 会额外探测 MySQL/Redis/Milvus,任一不可用即 503 —— 用它会变成
// "依赖抖动 => 前端宣称后端离线",与这句判断的原意不符。
HEALTH: { method: 'GET', path: '/internal/health/live', auth: false, raw: true },
A034: { method: 'POST', path: '/api/v1/auth/tokens', auth: false },
V001: { method: 'POST', path: '/api/v1/visitor-tokens', auth: false, raw: true },
P001: { method: 'GET', path: '/api/v1/products' },
@@ -76,21 +85,36 @@ const ENDPOINTS = Object.freeze({
T008: { method: 'GET', path: '/api/v1/users/me/transactions/{transactionNo}' },
T009: { method: 'GET', path: '/api/v1/users/me/cash-ledger' },
ADVISOR_PUBLISHED: { method: 'GET', path: '/api/v1/advisor/recommendations/published' },
// 投顾自助审核/发布自己生成的推荐方案(2026-09-14 起):
// 服务层不再额外要求 admin 角色,但仍要求 `product-recommendation:review` /
// `product-recommendation:publish` 两个权限码(仅 advisor 与 admin 持有)。
ADVISOR_REVIEW_RECOMMENDATION: { method: 'POST', path: '/api/v1/advisor/recommendations/{contentId}/reviews', idempotent: true },
ADVISOR_PUBLISH_RECOMMENDATION: { method: 'POST', path: '/api/v1/advisor/recommendations/{contentId}/publications', idempotent: true },
// ⚠️ 保留:前端契约测试(`tests/unit/api/test_portal_frontend.py`)把"页面会用到的端点"
// 固定成一张清单,**删注册会破坏它**。它对应 AD002,当前页面确实没调用
// (投顾本人没有"自己的投资目标",调它返回 404)—— 但**注册与调用是两件事**。
ADVISOR_GOAL: { method: 'GET', path: '/api/v1/advisor/investment-goals/current' },
// ⚠️ 这三个 POST 的响应形状**取决于是否带 `Idempotency-Key`**:
// · 不带键(ALLOCATION / ANALYSIS 的常态)→ **裸业务文档**,顶层键是 `status` / `allocation` / `summary`…,
// 必须标 `raw`,否则 `payload.data` 取到 `undefined`,整包被丢掉(2026-09-14 踩过)。
// · 带键(RECOMMEND 标了 `idempotent`,浏览器必带)→ **`{data, meta}` 信封**,且 `data` 是
// `{content_id, status, plan:{…}}` —— 真正的文档嵌在 `plan` 里(方案已落库待审核)。
// 所以 RECOMMEND **不能**标 `raw`,让 `request()` 正常解包;`plan` 这层嵌套由
// `actions-module` 的 `normalizeRecommend()` 归一。曾把 raw 误加到 RECOMMEND 上,
// 结果信封被当成数据,页面显示「后端返回状态:undefined」。
// ⚠️ 这三个 POST 的响应形状**取决于业务是否走完全程**,不只是"带不带 key":
// · 业务前置校验未通过(`profile_required` / `investment_goal_required` /
// `recommendation_input_invalid`)→ **裸文档** `{status:"…"}`。
// ⚠️ 这三个 early return 位于 `generate()` 的**最前面**,**在 key 判断之前**,
// 所以**带不带 key 都是裸文档** —— 这一点曾判断错。
// · 不带 key 且走完全程 → 裸业务文档(顶层键是 `status` / `allocation` / `summary`…)。
// · 带 key 且走完全程 → **`{data, meta}` 信封**,`data` 是 `{content_id, status, plan:{…}}`。
//
// 既然**同一个端点会返回两种形状**,前端就必须两种都能吃。这里三个 POST **一律标 `raw`**,
// 让 `request()` 原样交出完整响应体,再由 `actions-module` 的 `response.data ?? response`
// 与 `normalizeRecommend()` 统一归一:
// raw + 信封 → `response.data` 命中 → 取到业务数据
// raw + 裸文档 → `response.data` 是 undefined → 由 `??` 兜底取整个响应体
// 反过来(不标 raw)时,裸文档会被 `payload.data` 解成 `undefined`,
// 调用方只剩包装对象,页面就显示「后端返回状态:undefined」。
//
// 2026-09-14 曾把 `raw` 从 RECOMMEND 上去掉,理由是"它必带 key ⇒ 必然是信封"——
// 该前提不成立(见上面的 early return),结果前置校验一失败页面就显示 undefined。
ADVISOR_ANALYSIS: { method: 'POST', path: '/api/v1/advisor/portfolio-analysis', raw: true },
ADVISOR_ALLOCATION: { method: 'POST', path: '/api/v1/advisor/asset-allocation', raw: true },
ADVISOR_RECOMMEND: { method: 'POST', path: '/api/v1/advisor/recommendations', idempotent: true },
ADVISOR_RECOMMEND: { method: 'POST', path: '/api/v1/advisor/recommendations', idempotent: true, raw: true },
ADVISOR_CREATE_GOAL: { method: 'POST', path: '/api/v1/advisor/investment-goals', idempotent: true },
ADVISOR_CUSTOMER_GOAL: { method: 'GET', path: '/api/v1/advisor/customers/{customerId}/investment-goals/current' },
ADVISOR_CONFIRM_GOAL: { method: 'POST', path: '/api/v1/advisor/investment-goals/{goalNo}/confirmations', idempotent: true },
@@ -11,7 +11,7 @@
// 3. **合规熔断在前端** —— `SuitabilityService` 目前只判 `valid_until` 是否为空、
// 不比较是否过期,所以 FM-03 只能在这里拦。
import { apiClient } from '/static/portal/common/api-client.js?v=20260914-3';
import { apiClient } from '/static/portal/common/api-client.js?v=20260914-5';
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js?v=20260913';
import {
ACTION_DESCRIPTIONS,
@@ -24,7 +24,7 @@ import {
PIPELINE_BLOCK_INDEX,
RESULT_MESSAGES,
SCORING_WEIGHTS,
} from './advisor-config.js?v=20260914-advisor9';
} from './advisor-config.js?v=20260914-advisor11';
import {
assessmentFuseHits,
engineAllocation,
@@ -33,7 +33,7 @@ import {
engineRecommend,
engineScoring,
isAssessmentExpiring,
} from './advisor-engine.js?v=20260914-advisor9';
} from './advisor-engine.js?v=20260914-advisor11';
const READY_STATUSES = ['ready', 'pending_review'];
//: 需要先出小表单再执行的动作(推荐数量 / 客户目标 / 目标查询)。
@@ -164,7 +164,18 @@ export function createActionsModule({ output, alert, steps, amountInput, horizon
const body = dbId ? { customer_id: dbId } : {};
if (action === 'recommend') body.limit = pendingLimit;
const response = await apiClient.post(ACTION_ENDPOINTS[action], body);
const data = response.data ?? response;
// ⚠️ 这三个 POST 端点在 `api-client` 里**都标了 `raw`**,所以 `response.data` 拿到的是
// **完整响应体**,而它有**两种形状**(同一个端点会因业务分支返回不同形状):
// · 走完全程 → `{data:{content_id,status,plan}, meta}` 信封 —— 业务数据在内层 `.data`
// · 前置校验未通过(`profile_required` / `investment_goal_required` /
// `recommendation_input_invalid`)→ **裸文档** `{status:"…"}`,没有内层 `.data`
// (这三个 early return 在 `generate()` 最前面,**带不带 Idempotency-Key 都是裸文档**)
// 必须两种都吃:有内层 `.data` 就取它,否则取响应体自身。
// 只做 `response.data ?? response` 是不够的 —— `raw` 之后 `response.data` 恒有值,
// 兜底不生效,信封会被整包当成数据,页面又变成「后端返回状态:undefined」。
const payload = response.data;
const data = (payload && typeof payload === 'object'
&& payload.data && typeof payload.data === 'object') ? payload.data : payload;
return { data: action === 'recommend' ? normalizeRecommend(data) : data, source: 'real' };
}
@@ -210,7 +221,14 @@ export function createActionsModule({ output, alert, steps, amountInput, horizon
const badge = data.analysis_only ? '草稿 analysis_only' : '草稿 pending_review';
let html = header('生成推荐方案', source, tag(badge));
if (data.content_id) {
html += note(`方案已生成(编号 ${data.content_id}),状态待审核;须管理员审核发布后才对客户可见。`, 'info');
html += note(`方案已生成(编号 ${data.content_id}),状态待审核;审核通过并发布后才对客户可见。`, 'info');
// 投顾自助审核/发布(2026-09-14 起):原先审核与发布只对管理员开放,
// 投顾生成完草案后无法自行推进,草案永远停在 pending_review。
html += '<div class="advisor-actions advisor-actions--recommend">'
+ `<button class="button button--primary" type="button" data-advisor-review="${data.content_id}" data-decision="approved">审核通过</button>`
+ `<button class="button" type="button" data-advisor-review="${data.content_id}" data-decision="rejected">驳回</button>`
+ `<button class="button" type="button" data-advisor-publish="${data.content_id}">发布给客户</button>`
+ '</div>';
}
if (!products.length) {
html += note('当前没有通过适当性与证据校验的产品。', 'info');
@@ -357,9 +375,81 @@ export function createActionsModule({ output, alert, steps, amountInput, horizon
: (data && data.status && !READY_STATUSES.includes(data.status) ? PIPELINE_BLOCK_INDEX.default : null);
animate(blockedIndex, () => {
output.innerHTML = markupFor(action, data, source);
bindRecommendReviewActions();
});
}
/**
* 审核/发布按钮的绑定。结果区是 `innerHTML` 整体重建的,所以**每次渲染后都要重新绑**,
* 不能只在初始化时绑一次。
*/
function bindRecommendReviewActions() {
output.querySelectorAll('[data-advisor-review]').forEach((button) => {
button.addEventListener('click', () => reviewRecommendation(
button.dataset.advisorReview, button.dataset.decision,
));
});
output.querySelectorAll('[data-advisor-publish]').forEach((button) => {
button.addEventListener('click', () => publishRecommendation(button.dataset.advisorPublish));
});
}
/**
* 投顾自助审核:`POST /api/v1/advisor/recommendations/{id}/reviews`。
*
* 授权口径(2026-09-14 业务要求放开):不再要求 admin 角色,但**仍要求**
* `product-recommendation:review` 权限码;`reviewer_user_id` 如实落库,
* 谁审的、什么时候审的可审计。管理面复核队列(`/admin/advisor/pending-contents`)
* 依旧仅管理员可读,未放宽。
*/
async function reviewRecommendation(contentId, decision) {
if (!contentId) return;
const comment = decision === 'approved'
? '投顾自审通过'
: (window.prompt('请填写驳回理由(会写入草案留痕)') || '');
setLoading(decision === 'approved' ? '审核通过' : '驳回');
try {
await apiClient.post(
'ADVISOR_REVIEW_RECOMMENDATION',
{ decision, comment },
{ pathParams: { contentId } },
);
log(`推荐方案 ${contentId} 已${decision === 'approved' ? '审核通过(可发布)' : '驳回'}`,
decision === 'approved' ? 'success' : 'warning');
const row = output.querySelector('.advisor-actions--recommend');
if (row) {
row.innerHTML = decision === 'approved'
? `<button class="button button--primary" type="button" data-advisor-publish="${contentId}">发布给客户</button>`
: '<span class="advisor-inline-note">已驳回;可重新生成方案。</span>';
bindRecommendReviewActions();
}
showAlert(decision === 'approved'
? `方案 ${contentId} 已审核通过;点「发布给客户」后客户可见。`
: `方案 ${contentId} 已驳回。`, 'info');
} catch (error) {
apiClient.reportError(error);
showAlert(error.message || '审核失败。');
}
}
/** 投顾自助发布:`POST /api/v1/advisor/recommendations/{id}/publications`(须已审核通过)。 */
async function publishRecommendation(contentId) {
if (!contentId) return;
setLoading('发布给客户');
try {
await apiClient.post(
'ADVISOR_PUBLISH_RECOMMENDATION', {}, { pathParams: { contentId } },
);
log(`推荐方案 ${contentId} 已发布,客户可见`, 'success');
// 让「已发布交付物」列表自己刷新(`published-module.js` 监听该事件)。
window.dispatchEvent(new CustomEvent('advisor:published-refresh'));
showAlert(`方案 ${contentId} 已发布,已出现在「已发布交付物」中。`, 'info');
} catch (error) {
apiClient.reportError(error);
showAlert(error.message || '发布失败。');
}
}
async function execute(action) {
clearAlert();
const label = ACTION_LABELS[action];
@@ -2,12 +2,14 @@
//
// 数据口径见 `app/static/portal/README.md`:`/api/v1/advisor/recommendations/published`
// 返回**本人 + 名下归属客户**、且已通过审核发布的交付物(方案书 + 推荐方案两类)。
// 发布动作要求管理员,投顾侧只读 —— 所以这里只有列表,没有「发布」入口。
// 2026-09-14 起投顾可自行审核/发布推荐方案(`actions-module.js` 的结果区按钮),
// 因此这里监听 `advisor:published-refresh` 事件:发布成功后列表自动刷新,
// 不用让运营再手点一次「刷新」。
import { apiClient } from '/static/portal/common/api-client.js?v=20260914-3';
import { apiClient } from '/static/portal/common/api-client.js?v=20260914-5';
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js?v=20260913';
import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js?v=20260913';
import { CONTENT_TYPE_LABELS } from './advisor-config.js?v=20260914-advisor9';
import { CONTENT_TYPE_LABELS } from './advisor-config.js?v=20260914-advisor11';
export function createPublishedModule({ list, onClick }) {
async function load() {
@@ -34,5 +36,8 @@ export function createPublishedModule({ list, onClick }) {
}
}
// 投顾在结果区发布成功后派发该事件 → 列表自动刷新。
window.addEventListener('advisor:published-refresh', load);
return Object.freeze({ load });
}