投顾可自助审核/发布自己生成的推荐方案(原先只有管理员能推进草案)
## 现象与根因
投顾工作台生成推荐方案后,草案停在 `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:
@@ -41,6 +41,41 @@ async def published_recommendations(
|
||||
return await ProductRecommendationService().published(context)
|
||||
|
||||
|
||||
# ---- 投顾自助审核/发布(2026-09-14 新增)------------------------------------
|
||||
#
|
||||
# 为什么要有这两个**投顾侧**路由:审核/发布原先只在 `/api/v1/admin/advisor/...`
|
||||
# 下、且服务层还有 `admin=True` 角色闸门 —— 于是投顾生成完草案后**无法自行推进**,
|
||||
# 草案永远停在 `pending_review`,必须切到管理员账号才能审。业务要求投顾能审自己的方案。
|
||||
#
|
||||
# 与 admin 路由的关系:两者调用**同一个服务方法**,管理面复核队列
|
||||
# (`GET /api/v1/admin/advisor/pending-contents`)仍保持 admin 专属、未放宽。
|
||||
@advisor_router.post("/recommendations/{content_id}/reviews")
|
||||
async def advisor_review_recommendation(
|
||||
payload: dict[str, Any],
|
||||
content_id: int = Path(gt=0),
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, object]:
|
||||
decision = payload.get("decision")
|
||||
if decision not in {"approved", "rejected"}:
|
||||
from app.core.errors import ValidationAgentError
|
||||
|
||||
raise ValidationAgentError("decision 必须为 approved 或 rejected")
|
||||
comment = payload.get("comment", "")
|
||||
if not isinstance(comment, str):
|
||||
raise ValueError("comment must be a string")
|
||||
return await ProductRecommendationService().review(content_id, decision, comment, context, key)
|
||||
|
||||
|
||||
@advisor_router.post("/recommendations/{content_id}/publications")
|
||||
async def advisor_publish_recommendation(
|
||||
content_id: int = Path(gt=0),
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, object]:
|
||||
return await ProductRecommendationService().publish(content_id, context, key)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/advisor/pending-contents",
|
||||
dependencies=[Depends(enforce_advisor_rollout)],
|
||||
|
||||
@@ -283,7 +283,15 @@ class ProductRecommendationService:
|
||||
context: RequestContext,
|
||||
key: str | None,
|
||||
) -> dict[str, object]:
|
||||
await AuthorizationService.require(context, "product-recommendation:review", admin=True)
|
||||
# 审核权**只按权限码**,不再额外要求 admin 角色(2026-09-14 业务要求:
|
||||
# 投顾要能自己审核、发布自己生成的方案,否则草案永远停在 pending_review,
|
||||
# 演示/生产都得切到管理员账号才能推进)。
|
||||
# 安全边界仍在:`product-recommendation:review` 目前只授予 advisor 与 admin
|
||||
# 两个角色(`tools/grant_advisor_role.py` + 种子的 ADMIN_PERMISSIONS),
|
||||
# 且 `reviewer_user_id` 如实落库,审计可追。
|
||||
# ⚠️ 若合规上要求"四眼原则",把 `admin=True` 加回本行即可恢复管理员专属
|
||||
# (管理面复核队列 `pending_reviews` 仍保持 admin 专属,未放宽)。
|
||||
await AuthorizationService.require(context, "product-recommendation:review")
|
||||
|
||||
async def operation(session: Any) -> dict[str, object]:
|
||||
content = await session.get(ClientFacingContent, content_id, with_for_update=True)
|
||||
@@ -314,7 +322,9 @@ class ProductRecommendationService:
|
||||
async def publish(
|
||||
self, content_id: int, context: RequestContext, key: str | None
|
||||
) -> dict[str, object]:
|
||||
await AuthorizationService.require(context, "product-recommendation:publish", admin=True)
|
||||
# 同上:发布权按权限码判定(advisor 与 admin 均持有),不再要求 admin 角色。
|
||||
# 恢复到"管理员专属"只需把 `admin=True` 加回。
|
||||
await AuthorizationService.require(context, "product-recommendation:publish")
|
||||
|
||||
async def operation(session: Any) -> dict[str, object]:
|
||||
content = await session.get(ClientFacingContent, content_id, with_for_update=True)
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -83,3 +83,56 @@ async def test_pending_items_carry_the_key_each_content_type_needs() -> None:
|
||||
)
|
||||
else:
|
||||
assert item["goal_no"] is None, "推荐方案不该有 goal_no"
|
||||
|
||||
|
||||
async def test_advisor_can_review_and_publish_own_recommendation() -> None:
|
||||
"""投顾**自助**审核并发布自己生成的推荐方案(2026-09-14 业务要求放宽)。
|
||||
|
||||
放宽前 `review` / `publish` 都带 `admin=True` 角色闸门 —— 投顾生成完草案后
|
||||
无法自行推进,草案永远停在 `pending_review`,只能切到管理员账号去审。
|
||||
放宽后仍要求权限码 `product-recommendation:review` / `:publish`
|
||||
(仅 advisor 与 admin 持有),`reviewer_user_id` 如实落库。
|
||||
|
||||
⚠️ 与上一条测试互补:**管理面复核队列依旧仅管理员可读**(那条断言未放宽)。
|
||||
"""
|
||||
app = create_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://test", timeout=60
|
||||
) as client:
|
||||
advisor = await _token(client, "advisor_t", "abc12345")
|
||||
auth = {"Authorization": f"Bearer {advisor}"}
|
||||
|
||||
generated = await client.post(
|
||||
"/api/v1/advisor/recommendations",
|
||||
json={"customer_id": 9001, "limit": 3},
|
||||
headers={**auth, "Idempotency-Key": "advisor-self-review-flow-0001"},
|
||||
)
|
||||
assert generated.status_code == 200, generated.text
|
||||
payload = generated.json()
|
||||
data = payload.get("data") if isinstance(payload.get("data"), dict) else payload
|
||||
content_id = data.get("content_id")
|
||||
if not content_id:
|
||||
# 该客户缺风险测评或已确认投资目标时,`generate` 会前置返回业务状态。
|
||||
# 这是**数据前置**问题(见 `tools/seed_advisor_demo.py`),不是本断言的目标。
|
||||
pytest.skip(f"客户 9001 尚不具备生成条件:{data.get('status')}")
|
||||
|
||||
reviewed = await client.post(
|
||||
f"/api/v1/advisor/recommendations/{content_id}/reviews",
|
||||
json={"decision": "approved", "comment": "投顾自审通过(集成测试)"},
|
||||
headers={**auth, "Idempotency-Key": "advisor-self-review-flow-0002"},
|
||||
)
|
||||
assert reviewed.status_code == 200, reviewed.text
|
||||
assert reviewed.json()["data"]["status"] == "approved"
|
||||
|
||||
published = await client.post(
|
||||
f"/api/v1/advisor/recommendations/{content_id}/publications",
|
||||
headers={**auth, "Idempotency-Key": "advisor-self-review-flow-0003"},
|
||||
)
|
||||
assert published.status_code == 200, published.text
|
||||
assert published.json()["data"]["status"] == "published"
|
||||
|
||||
listed = await client.get("/api/v1/advisor/recommendations/published", headers=auth)
|
||||
assert listed.status_code == 200, listed.text
|
||||
ids = {str(row.get("content_id") or row.get("id")) for row in listed.json()["data"]}
|
||||
assert str(content_id) in ids, "发布后应出现在「已发布交付物」列表里"
|
||||
|
||||
Reference in New Issue
Block a user