From 467d2b5169527f50623492830035b8daf972854f 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: Mon, 14 Sep 2026 23:27:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=95=E9=A1=BE=E5=8F=AF=E8=87=AA=E5=8A=A9?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8/=E5=8F=91=E5=B8=83=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=9A=84=E6=8E=A8=E8=8D=90=E6=96=B9=E6=A1=88?= =?UTF-8?q?=EF=BC=88=E5=8E=9F=E5=85=88=E5=8F=AA=E6=9C=89=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E8=83=BD=E6=8E=A8=E8=BF=9B=E8=8D=89=E6=A1=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象与根因 投顾工作台生成推荐方案后,草案停在 `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` 通过。 --- app/api/controllers/recommendations.py | 35 ++++++ app/service/product_recommendation_service.py | 14 ++- app/static/portal/common/api-client.js | 44 ++++++-- .../dashboard/actions-module.js | 100 +++++++++++++++++- .../dashboard/published-module.js | 11 +- .../test_advisor_review_queue_mysql.py | 53 ++++++++++ 6 files changed, 237 insertions(+), 20 deletions(-) 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 += '