diff --git a/app/api/controllers/recommendations.py b/app/api/controllers/recommendations.py index a306d09..72ceb26 100644 --- a/app/api/controllers/recommendations.py +++ b/app/api/controllers/recommendations.py @@ -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)], diff --git a/app/service/product_recommendation_service.py b/app/service/product_recommendation_service.py index 089349c..2b3ce83 100644 --- a/app/service/product_recommendation_service.py +++ b/app/service/product_recommendation_service.py @@ -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) diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index db88538..e73e4c7 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -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 }, diff --git a/app/static/portal/employee-advisor/dashboard/actions-module.js b/app/static/portal/employee-advisor/dashboard/actions-module.js index 60ec4b8..161b249 100644 --- a/app/static/portal/employee-advisor/dashboard/actions-module.js +++ b/app/static/portal/employee-advisor/dashboard/actions-module.js @@ -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 += '
' + + `` + + `` + + `` + + '
'; } 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' + ? `` + : '已驳回;可重新生成方案。'; + 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]; diff --git a/app/static/portal/employee-advisor/dashboard/published-module.js b/app/static/portal/employee-advisor/dashboard/published-module.js index ec91a07..6bf0859 100644 --- a/app/static/portal/employee-advisor/dashboard/published-module.js +++ b/app/static/portal/employee-advisor/dashboard/published-module.js @@ -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 }); } diff --git a/tests/integration/test_advisor_review_queue_mysql.py b/tests/integration/test_advisor_review_queue_mysql.py index 1c0e6c8..6cbc940 100644 --- a/tests/integration/test_advisor_review_queue_mysql.py +++ b/tests/integration/test_advisor_review_queue_mysql.py @@ -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, "发布后应出现在「已发布交付物」列表里"