From de55c5c60c98f2b8ef29f2d2a7d620b73f24a1e7 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 00:17:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(advisor):=20=E7=99=BB=E8=AE=B0=2017=20?= =?UTF-8?q?=E4=B8=AA=E6=8A=95=E9=A1=BE=E7=AB=AF=E7=82=B9=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E5=A4=8D=E6=A0=B8=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=88A047=20=E5=BE=85=E5=AE=A1=E9=98=9F=E5=88=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. docs/05 §19 补登 17 个投顾端点 这批端点此前**只存在于代码中**,§19 一条都没登记;而 §12 写的入口 `/api/v1/advisory-plans/**` 与实际路径 `/api/v1/advisor/**` 也不符(已修正)。 - **A041–A046**:管理员治理(配置回测、画像标签与漂移复核、推荐方案审核与发布) - **AD001–AD011**:投顾自用。**新开 `AD` 号段**的理由:它与 A 段是两个不同的权限面 —— A 段是 `/api/v1/admin/**` 管理面,AD 段是 `/api/v1/advisor/**` 投顾自用; 混在一个号段里,"这条到底谁能调"就得逐条去读权限列。 - 另加 AD 段说明块:`investment-goal` 的两套权限码(`...:self` / `...:customer`)、 AD006/AD007 虽在投顾路径下却要求 `admin`、灰度开关 `enforce_advisor_rollout` 前置、 幂等范围(AD008/AD009 无幂等头)、以及 404/409 的失败口径。 §19 现为 **90 个端点 / 9 个号段**,无重复。 ## 2. 管理员复核入口 + A047 待审队列 **发现一个让审核链路不可达的缺口**:`review` / `publish` 都要求调用方先拿到键 (推荐方案是 `content_id`、方案书是 `goal_no`),而此前**没有任何端点能列出待审内容** —— 管理员拿不到键,投顾生成的东西就永远停在待审状态。 - 新增 `GET /api/v1/admin/advisor/pending-contents`(编号 **A047**):一次返回两类待审内容。 两类内容的"待审"取值不同(推荐方案 `pending_review`、方案书 `pending`), 只判其中一个会整类漏掉,所以用 `PENDING_STATES` 一并匹配。 - **为方案书一并查出 `goal_no`** —— 它的审核/发布端点(AD006/AD007)按 `goal_no` 寻址, 只给 `content_id` 的话管理员拿到列表也调不动。已由 integration 测试守住这一点。 - 管理员工作台新增「投顾复核」标签页:列出待审内容,支持审核通过 / 驳回 / 发布; 前端按 `content_type` 自动选择端点、寻址键与载荷 (方案书发布要 `{publish: true}`,推荐方案发布不读 body)。 - 发布前校验状态:未审核通过不允许发布,与 `publish_book` 的 `IllegalState` 一致。 ## 3. ⚠️ 同时发现:投顾的三个分析功能对投顾本人不可用 `ProductRecommendationQuery` **没有 `customer_id`** 字段,而 `generate` 用的是 `int(context.user_id)`(`product_recommendation_service.py:69`)—— 即**把投顾自己** 当成了服务对象。投顾是员工、没有风险测评与持仓,于是实测: POST /api/v1/advisor/recommendations → {"status": "profile_required"} POST /api/v1/advisor/asset-allocation → {"status": "profile_required"} **组合分析、资产配置、生成推荐草案这三个功能,投顾调用必然拿不到结果。** 这是"投顾功能很奇怪"的直接来源之一。修它要改接口契约(加 `customer_id`、 并确定"投顾能对哪些客户生成"的权限口径),属产品决策,未在本提交内改动。 验证:unit+contract **1397 passed**;新增 integration 用例 2 passed;ruff 通过; mypy 251 文件 0 错;A047 实测管理员 200(带出方案书的 `goal_no`)、投顾 403。 --- app/api/controllers/recommendations.py | 16 ++++ app/service/product_recommendation_service.py | 77 ++++++++++++++++- app/static/portal/common/api-client.js | 5 ++ .../employee-console/workspace/index.html | 4 +- .../employee-console/workspace/workspace.js | 82 +++++++++++++++++- docs/05-接口文档.md | 48 ++++++++++- .../test_advisor_review_queue_mysql.py | 85 +++++++++++++++++++ 7 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 tests/integration/test_advisor_review_queue_mysql.py diff --git a/app/api/controllers/recommendations.py b/app/api/controllers/recommendations.py index 90d76e8..a306d09 100644 --- a/app/api/controllers/recommendations.py +++ b/app/api/controllers/recommendations.py @@ -41,6 +41,22 @@ async def published_recommendations( return await ProductRecommendationService().published(context) +@admin_router.get( + "/advisor/pending-contents", + dependencies=[Depends(enforce_advisor_rollout)], +) +async def pending_advisor_contents( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + """待审核的投顾内容(推荐方案 + 投资方案书)。编号 `A047`。 + + 补这个入口的原因:审核/发布端点都要求先拿到 `content_id`,而此前**没有**任何 + 端点能列出待审内容,管理员拿不到 id ⇒ 审核链路不可达。返回体里的 + `content_type` 用于前端区分两类内容。 + """ + return await ProductRecommendationService().pending_reviews(context) + + @admin_router.post( "/advisor/recommendations/{content_id}/reviews", dependencies=[Depends(enforce_advisor_rollout)], diff --git a/app/service/product_recommendation_service.py b/app/service/product_recommendation_service.py index b061f30..2d41123 100644 --- a/app/service/product_recommendation_service.py +++ b/app/service/product_recommendation_service.py @@ -13,7 +13,7 @@ from app.core.product_recommendation_contracts import ProductRecommendationQuery from app.infrastructure.db import SessionFactory from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver from app.model.audit import InteractionAudit -from app.model.investment_goal import ClientFacingContent +from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent from app.repository.advisor_product_repository import ( AdvisorProductRepository, AuthoritativeProductCandidate, @@ -26,6 +26,11 @@ from app.service.profile_governance_service import ProfileGovernanceService from app.service.relationship_service import RelationshipService from app.service.suitability_service import SuitabilityService +#: 投资方案书的 `content_type`。它与推荐方案同处 `client_facing_content` 表, +#: 由 `InvestmentGoalService` 写入;两者的**后续动作用不同键寻址**: +#: 推荐方案用 `content_id`,方案书用 `goal_no`。 +BOOK_CONTENT_TYPE = "investment_goal_book" + class ProductRecommendationService: CONTENT_TYPE = "advisor_recommendation_plan" @@ -37,6 +42,10 @@ class ProductRecommendationService: #: (`product_recommendation_service.review`),方案书发布后置 `published` #: (`investment_goal_service.publish_book`)。只判 `approved` 会把方案书整类漏掉。 PUBLISHED_STATES: tuple[str, ...] = ("approved", "published") + #: 两类内容的"待审"取值同样不同:推荐方案生成时置 `pending_review` + #: (`ProductRecommendationService.generate`),方案书创建草稿时置 `pending` + #: (`InvestmentGoalService.create`)。只判其中一个会把另一类整类漏掉。 + PENDING_STATES: tuple[str, ...] = ("pending", "pending_review") def __init__( self, @@ -370,6 +379,72 @@ class ProductRecommendationService: } + async def pending_reviews(self, context: RequestContext) -> dict[str, object]: + """管理面复核队列:待审核的推荐方案与投资方案书。 + + ## 为什么必须补这个入口 + + `review` / `publish` 都要求调用方**先知道 `content_id`**,而在此之前 + **没有任何端点能列出待审内容** —— 管理员拿不到 id,整条审核链路实际不可达: + 投顾生成草案后它会一直停在待审状态,没有人能推进它。 + + ## 口径 + + - **不按客户归属过滤**:这是管理面的复核队列,管理员要看**全部**待审内容; + 权限由 `product-recommendation:review`(`admin=True`)把关, + 比 `published` 用的 `...:read:self` 更严。 + - **一次返回两类内容**(推荐方案 + 方案书),前端按 `content_type` 区分。 + 它们同处 `client_facing_content` 表,只是 `review_status` 取值不同。 + - 按 `created_at` **升序**:先提交的先审,避免新草案把旧的挤下去。 + """ + await AuthorizationService.require( + context, "product-recommendation:review", admin=True + ) + async with self.session_factory() as session: + rows = list( + await session.scalars( + select(ClientFacingContent) + .where( + ClientFacingContent.content_type.in_(self.CLIENT_CONTENT_TYPES), + ClientFacingContent.review_status.in_(self.PENDING_STATES), + ) + .order_by(ClientFacingContent.created_at.asc()) + .limit(50) + ) + ) + # ⚠️ 两类内容的后续动作用**不同的键**寻址: + # · 推荐方案:`content_id` → A045 / A046 + # · 投资方案书:`goal_no` → AD006 / AD007 + # 待审列表本身只有 `content_id`,所以这里为方案书一并查出 `goal_no`; + # 否则管理员拿到了列表也调不动那两个端点(缺的就是这个映射)。 + book_ids = [row.id for row in rows if row.content_type == BOOK_CONTENT_TYPE] + goal_nos: dict[int, str] = {} + if book_ids: + pairs = await session.execute( + select( + AdvisorInvestmentGoal.goal_book_content_id, + AdvisorInvestmentGoal.goal_no, + ).where(AdvisorInvestmentGoal.goal_book_content_id.in_(book_ids)) + ) + goal_nos = {int(content_id): str(no) for content_id, no in pairs.all()} + return { + "data": [ + { + "content_id": str(row.id), + "customer_id": str(row.customer_id), + "content_type": row.content_type, + "review_status": row.review_status, + "plan": row.draft_content, + "created_at": row.created_at.isoformat() if row.created_at else None, + # 仅方案书有值;推荐方案为 None(它按 content_id 寻址) + "goal_no": goal_nos.get(row.id), + } + for row in rows + ], + "meta": {"trace_id": context.trace_id}, + } + + async def product_recommendation_tool( arguments: ProductRecommendationQuery, context: RequestContext ) -> dict[str, object]: diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index 2a9f33f..0029bcb 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -24,6 +24,9 @@ const ENDPOINTS = Object.freeze({ A033: { method: 'GET', path: '/api/v1/admin/audit-records' }, ADMIN_HANDOVERS: { method: 'GET', path: '/api/v1/admin/customer-service/handover-tickets' }, ADMIN_HANDOVER_DETAIL: { method: 'GET', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}' }, + ADMIN_ADVISOR_PENDING: { method: 'GET', path: '/api/v1/admin/advisor/pending-contents' }, + ADMIN_ADVISOR_REVIEW: { method: 'POST', path: '/api/v1/admin/advisor/recommendations/{contentId}/reviews', idempotent: true }, + ADMIN_ADVISOR_PUBLISH: { method: 'POST', path: '/api/v1/admin/advisor/recommendations/{contentId}/publications', idempotent: true }, ONB001: { method: 'GET', path: '/api/v1/onboarding/risk-questionnaire' }, ONB002: { method: 'POST', path: '/api/v1/onboarding/risk-questionnaire/submissions', idempotent: true }, R001: { method: 'POST', path: '/api/v1/agent-runs' }, @@ -62,6 +65,8 @@ const ENDPOINTS = Object.freeze({ 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 }, ADVISOR_GOAL_BOOK: { method: 'GET', path: '/api/v1/advisor/investment-goals/{goalNo}/goal-book' }, + ADVISOR_REVIEW_BOOK: { method: 'POST', path: '/api/v1/advisor/investment-goals/{goalNo}/goal-book/reviews', idempotent: true }, + ADVISOR_PUBLISH_BOOK: { method: 'POST', path: '/api/v1/advisor/investment-goals/{goalNo}/goal-book/publications', idempotent: true }, OFFSITE_MAILS: { method: 'GET', path: '/api/v1/offsite-fund/mails' }, OFFSITE_MAILBOX: { method: 'GET', path: '/api/v1/offsite-fund/mailbox-status' }, }); diff --git a/app/static/portal/employee-console/workspace/index.html b/app/static/portal/employee-console/workspace/index.html index d8749ea..ea5af9e 100644 --- a/app/static/portal/employee-console/workspace/index.html +++ b/app/static/portal/employee-console/workspace/index.html @@ -21,6 +21,7 @@ +
@@ -35,10 +36,11 @@ +

详情

确认操作

- + diff --git a/app/static/portal/employee-console/workspace/workspace.js b/app/static/portal/employee-console/workspace/workspace.js index 08266b5..cafa9be 100644 --- a/app/static/portal/employee-console/workspace/workspace.js +++ b/app/static/portal/employee-console/workspace/workspace.js @@ -24,7 +24,7 @@ function table(items, columns, action) { if (requireAdmin()) { mountShell({ active: 'admin-workspace', mode: 'admin' }); const context = getAuthContext(); - const state = { roles: [], releases: [], endpoints: [], audits: [], handovers: [], candidates: [], action: null }; + const state = { roles: [], releases: [], endpoints: [], audits: [], handovers: [], candidates: [], advisor: [], action: null }; const targets = { roles: document.querySelector('[data-role-table]'), releases: document.querySelector('[data-release-table]'), @@ -32,6 +32,7 @@ if (requireAdmin()) { audits: document.querySelector('[data-audit-table]'), handovers: document.querySelector('[data-handover-table]'), candidates: document.querySelector('[data-candidate-table]'), + advisor: document.querySelector('[data-advisor-table]'), }; const detailDialog = document.querySelector('[data-admin-detail]'); const actionDialog = document.querySelector('[data-admin-action]'); @@ -179,6 +180,58 @@ if (requireAdmin()) { } catch (error) { apiClient.reportError(error); renderError(targets.candidates, error, loadCandidates); } } + // ---- 投顾复核:推荐方案与投资方案书的待审队列 ---- + // + // 为什么需要它:审核/发布端点都要求调用方**先拿到键** —— 推荐方案是 `content_id`、 + // 方案书是 `goal_no`。而此前**没有任何端点能列出待审内容**,管理员拿不到键, + // 于是投顾生成的东西永远停在待审状态、没有人能推进。 + const ADVISOR_CONTENT_LABELS = { + advisor_recommendation_plan: '产品推荐方案', + investment_goal_book: '投资目标方案书', + }; + + async function loadAdvisorReviews() { + renderLoading(targets.advisor, 3); + try { + const response = await apiClient.get('ADMIN_ADVISOR_PENDING'); + state.advisor = Array.isArray(response.data) ? response.data : []; + if (!state.advisor.length) { + renderEmpty(targets.advisor, '暂无待审内容', '投顾生成推荐草案或录入客户目标后,会出现在这里等待复核。'); + return; + } + const rows = state.advisor.map((item) => ({ + ...item, + content_label: ADVISOR_CONTENT_LABELS[item.content_type] || item.content_type, + })); + targets.advisor.innerHTML = table( + rows, + [['content_id', '内容 ID'], ['content_label', '类型'], ['customer_id', '客户 ID'], ['review_status', '状态'], ['created_at', '提交时间']], + (item) => `
`, + ); + targets.advisor.querySelectorAll('[data-advisor-action]').forEach((button) => button.addEventListener('click', () => openAdvisorAction(button.dataset.advisorAction, button.dataset.contentId))); + } catch (error) { apiClient.reportError(error); renderError(targets.advisor, error, loadAdvisorReviews); } + } + + function openAdvisorAction(action, contentId) { + const item = state.advisor.find((row) => String(row.content_id) === String(contentId)); + if (!item) { showToast('待审列表已变化,请刷新后重试', 'error'); return; } + const label = ADVISOR_CONTENT_LABELS[item.content_type] || item.content_type; + // 发布前必须已审核通过:推荐方案要 `approved`,方案书也要 `approved` + // (`publish_book` 里 `review_status != "approved"` 直接抛 IllegalState)。 + if (action === 'publish' && item.review_status !== 'approved') { + showToast('该内容尚未审核通过,不能发布', 'error'); + return; + } + state.action = { type: 'advisor', action, item }; + openActionDialog( + `${action === 'publish' ? '发布' : action === 'approve' ? '审核通过' : '驳回'}${label}`, + action === 'publish' + ? '发布后客户与投顾即可看到该内容。' + : '审核结论会留痕;驳回后内容不会对外展示。', + action !== 'publish', + ); + } + function openReleaseAction(action, releaseId) { const config = { validate: { title: '提交配置校验', copy: '校验通过后,配置版本将进入待审核状态。', endpoint: 'A004', body: {} }, @@ -212,6 +265,30 @@ if (requireAdmin()) { const comment = form.elements.comment.value.trim(); submit.disabled = true; try { + if (state.action.type === 'advisor') { + const { action, item } = state.action; + const isBook = item.content_type === 'investment_goal_book'; + if (isBook && !item.goal_no) { + throw new Error('方案书缺少 goal_no,无法审核,请刷新后重试'); + } + // 两类内容的**端点与寻址键都不同**: + // 推荐方案 content_id → A045 / A046 + // 方案书 goal_no → AD006 / AD007 + const endpoint = isBook + ? (action === 'publish' ? 'ADVISOR_PUBLISH_BOOK' : 'ADVISOR_REVIEW_BOOK') + : (action === 'publish' ? 'ADMIN_ADVISOR_PUBLISH' : 'ADMIN_ADVISOR_REVIEW'); + const pathParams = isBook ? { goalNo: item.goal_no } : { contentId: item.content_id }; + // 载荷同样不同:方案书发布要 `{publish: true}`,推荐方案发布端点不读 body + const body = action === 'publish' + ? (isBook ? { publish: true } : {}) + : { decision: action === 'approve' ? 'approved' : 'rejected', ...(comment ? { comment } : {}) }; + await apiClient.post(endpoint, body, { pathParams }); + showToast('投顾内容状态已更新'); + await loadAdvisorReviews(); + actionDialog.close(); + renderMetrics(); + return; + } if (state.action.type === 'candidate') { await apiClient.post('A040', { decision: state.action.decision, comment }, { pathParams: { candidateId: state.action.candidateId } }); showToast(state.action.decision === 'approved' ? '画像候选已批准' : '画像候选已驳回'); @@ -238,6 +315,7 @@ if (requireAdmin()) { })); document.querySelector('[data-identity-form]').addEventListener('submit', queryIdentity); document.querySelector('[data-reload-audit]').addEventListener('click', loadAudits); + document.querySelector('[data-reload-advisor]').addEventListener('click', loadAdvisorReviews); document.querySelector('[data-admin-action-form]').addEventListener('submit', submitAdminAction); document.querySelectorAll('[data-close-detail]').forEach((button) => button.addEventListener('click', () => detailDialog.close())); document.querySelectorAll('[data-close-admin-action]').forEach((button) => button.addEventListener('click', () => actionDialog.close())); @@ -245,7 +323,7 @@ if (requireAdmin()) { async function initialize() { document.querySelector('[data-admin-metrics]').innerHTML = Array.from({ length: 4 }, () => '
').join(''); try { await hydrateIdentity(); } catch (error) { apiClient.reportError(error); showToast(error.message || '权限加载失败', 'error'); } - await Promise.all([loadRoles(), loadReleases(), loadEndpoints(), loadAudits(), loadHandovers(), loadCandidates()]); + await Promise.all([loadRoles(), loadReleases(), loadEndpoints(), loadAudits(), loadHandovers(), loadCandidates(), loadAdvisorReviews()]); renderMetrics(); } diff --git a/docs/05-接口文档.md b/docs/05-接口文档.md index 1388287..5b727cd 100644 --- a/docs/05-接口文档.md +++ b/docs/05-接口文档.md @@ -955,7 +955,7 @@ Outbox 消费者按 `event_id` 幂等。失败事件保留并重试,超过阈 | 业务域 | 接口入口 | 归属文档 | Agent 边界 | |---|---|---|---| | 客服工单 | `/api/v1/customer-service/handover-tickets/**` | 客服业务文档 | 可生成摘要和转人工请求,不分配、接单、解决或关闭工单 | -| 投顾方案 | `/api/v1/advisory-plans/**` | 投顾业务文档 | 只生成分析草案,不代替投顾审核发布 | +| 投顾方案 | `/api/v1/advisor/**`(编号见 §19 的 `AD` 段与 `A041`–`A046`) | 本文 §19 | 只生成分析草案,不代替投顾审核发布 | | 场内模拟交易 | `/api/v1/sim-orders/**` | 交易业务文档 | 只读查询,不创建、确认或撤销委托 | | 风控扫描 | `/api/v1/risk/**` | 风控业务文档 | 可解释规则结果,不启动人工处置 | | 风险预警 | `/api/v1/risk/**` | 风控业务文档 | 只读分析,不确认、升级或关闭预警 | @@ -1108,6 +1108,24 @@ GET /internal/metrics | M004 | `POST /api/v1/users/me/memory-candidates/{candidate_id}/decisions` | `memory:candidate:confirm` | 必须 | `200` | 用户确认/拒绝 | | A039 | `GET /api/v1/admin/customer-profile-candidates` | `memory:candidate:review` | 否 | `200` | 候选审核列表 | | A040 | `POST /api/v1/admin/customer-profile-candidates/{candidate_id}/reviews` | `memory:candidate:review` | 必须 | `200` | 候选审核 | +| A041 | `POST /api/v1/admin/advisor/asset-allocation-backtests` | `asset-allocation:backtest`(+`admin`) | 必须 | `201` | 配置回测 | +| A042 | `GET /api/v1/admin/advisor/profile-tags` | `profile-governance:read`(+`admin`) | 否 | `200` | 敏感访问 | +| A043 | `GET /api/v1/admin/advisor/profile-drift-reviews` | `profile-governance:read`(+`admin`) | 否 | `200` | 敏感访问 | +| A044 | `POST /api/v1/admin/advisor/profile-drift-reviews/{review_id}/reviews` | `profile-governance:review`(+`admin`) | 必须 | `200` | 画像漂移复核 | +| A045 | `POST /api/v1/admin/advisor/recommendations/{content_id}/reviews` | `product-recommendation:review`(+`admin`) | 必须 | `200` | 推荐方案审核 | +| A046 | `POST /api/v1/admin/advisor/recommendations/{content_id}/publications` | `product-recommendation:publish`(+`admin`) | 必须 | `200` | 推荐方案发布 | +| A047 | `GET /api/v1/admin/advisor/pending-contents` | `product-recommendation:review`(+`admin`) | 否 | `200` | 否 | +| AD001 | `POST /api/v1/advisor/investment-goals` | `investment-goal:write:self` / `:customer` | 必须 | `201` | 投资目标创建 | +| AD002 | `GET /api/v1/advisor/investment-goals/current` | `investment-goal:read:self` | 否 | `200` | 否 | +| AD003 | `GET /api/v1/advisor/customers/{customer_id}/investment-goals/current` | `investment-goal:read:self` / `:customer` | 否 | `200` | 否 | +| AD004 | `POST /api/v1/advisor/investment-goals/{goal_no}/confirmations` | `investment-goal:confirm:self` / `:customer` | 必须 | `200` | 目标确认 | +| AD005 | `GET /api/v1/advisor/investment-goals/{goal_no}/goal-book` | `investment-goal:read:self` / `:customer` | 否 | `200` | 否 | +| AD006 | `POST /api/v1/advisor/investment-goals/{goal_no}/goal-book/reviews` | `investment-goal:review`(+`admin`) | 必须 | `200` | 方案书审核 | +| AD007 | `POST /api/v1/advisor/investment-goals/{goal_no}/goal-book/publications` | `investment-goal:publish`(+`admin`) | 必须 | `200` | 方案书发布 | +| AD008 | `POST /api/v1/advisor/portfolio-analysis` | `portfolio-analysis:read:self` | 否 | `200` | 否 | +| AD009 | `POST /api/v1/advisor/asset-allocation` | `asset-allocation:generate:self` | 否 | `200` | 否 | +| AD010 | `POST /api/v1/advisor/recommendations` | `product-recommendation:generate:self` | 必须 | `200` | 否 | +| AD011 | `GET /api/v1/advisor/recommendations/published` | `product-recommendation:read:self` | 否 | `200` | 否 | | K001 | `GET /api/v1/knowledge-references/{reference_token}` | `knowledge:reference:read` | 否 | `200` | 否 | | K002 | `POST /api/v1/knowledge/upload` | `knowledge:manage` | 否 | `201` | 知识文档变更 | | K003 | `GET /api/v1/knowledge/list` | `knowledge:manage` | 否 | `200` | 否 | @@ -1192,6 +1210,34 @@ GET /internal/metrics 业务域接口 `/customer-service/handover-tickets/**`、`/advisory-plans/**`、`/sim-orders/**`、`/risk-scans/**` 和 `/risk-alerts/**` 的具体方法、请求体、领域状态机和错误码分别由对应业务文档登记;它们仍必须遵守本文第 3-5、11 和 12 节。 +> **AD 段(投顾自用)与 A041–A046(投顾治理)的六点说明**: +> +> 这批端点原先**只存在于代码中**,`§19` 一条都没登记(2026-09-13 补登)。当时 §12 写的 +> 入口是 `/api/v1/advisory-plans/**`,与实际路径 `/api/v1/advisor/**` **不符**, +> 也已一并修正。门禁脚本 `check_docs_endpoint_ids.py` 只校验 §19 **内部**编号唯一性, +> **查不出"代码里有端点、文档里没登记"**这类缺口 —— 新增端点时请按 §20 主动登记。 +> +> - **为什么新开 `AD` 号段**:这批端点落在 `/api/v1/advisor/**`,与 A 段的 +> `/api/v1/admin/**` 是两个不同的权限面(A 段是管理面,AD 段是投顾自用)。 +> 混在一个号段里,"这条到底是投顾能调还是只有管理员能调"就得逐条去读权限列。 +> - **`investment-goal` 的两套权限码**:`investment-goal::self`(本人)与 +> `investment-goal::customer`(名下客户),由 +> `InvestmentGoalService._assert_customer_access` 按 `customer_id` 是否等于 +> `context.user_id` 选择。`:customer` 那一支还要求数据范围是 `all`,或者 +> `own_customers` 且该客户确实在 `sys_customer_assignment` 里 —— 否则返回 +> `404 客户不可访问`(**注意是 404 不是 403**:不向调用方泄露"该客户存在但你没权看")。 +> - **AD006 / AD007 虽在投顾路径下,却要求 `admin`**:方案书的审核与发布是**管理员的 +> 复核动作**(`review_book` / `publish_book` 都带 `admin=True`),投顾本人发不出来。 +> 这是有意的复核环节,不是遗漏。 +> - **`enforce_advisor_rollout` 是额外前置**:AD 段每一条都挂了投顾灰度开关, +> 未放行时在鉴权之后、业务逻辑之前就被拦下。 +> - **幂等**:AD001 / AD004 / AD006 / AD007 / AD010 与 A041 / A044 / A045 / A046 接受 +> `Idempotency-Key`;**AD008 / AD009(组合分析、资产配置)没有幂等头** —— +> 它们是纯分析入口,不落业务单据。 +> - **失败口径**:目标或方案书不存在 → `404`;状态不允许(重复确认、未审核就发布) +> → `409`。⚠️ 409 目前一律复用 `RUN_NOT_CANCELLABLE` 这个码(见 §3.6), +> 所以"投资目标不能确认"会报出字面像"运行不可取消"的码,属于已知的文档缺口。 + > **P001(公开产品列表)的四点说明**: > > - **鉴权口径:要求令牌但不校验权限码。** 访客令牌的角色是 `visitor`、**不带任何权限** diff --git a/tests/integration/test_advisor_review_queue_mysql.py b/tests/integration/test_advisor_review_queue_mysql.py new file mode 100644 index 0000000..1c0e6c8 --- /dev/null +++ b/tests/integration/test_advisor_review_queue_mysql.py @@ -0,0 +1,85 @@ +"""真实 MySQL:管理员待审队列(A047)与审核链路的可达性。 + +## 为什么单独守这一条 + +`review` / `publish` 都要求调用方**先拿到键** —— 推荐方案是 `content_id`、 +投资方案书是 `goal_no`。在 A047 之前**没有任何端点能列出待审内容**, +管理员拿不到键,于是投顾生成的东西永远停在待审状态、没有人能推进。 + +本文件守住三件事: +1. 管理员能读到待审队列; +2. **投顾读不到**(这条队列是管理面的,权限比 `published` 更严); +3. **方案书必须带 `goal_no`** —— 否则前端拿到列表也调不动 AD006/AD007。 +""" + +from __future__ import annotations + +import httpx +import pytest + +from app.main import create_app + +pytestmark = pytest.mark.integration + +PENDING_PATH = "/api/v1/admin/advisor/pending-contents" + +BOOK_TYPE = "investment_goal_book" +RECOMMENDATION_TYPE = "advisor_recommendation_plan" + + +async def _token(client: httpx.AsyncClient, username: str, password: str) -> str: + response = await client.post( + "/api/v1/auth/tokens", json={"username": username, "password": password} + ) + assert response.status_code == 200, response.text + return response.json()["data"]["access_token"] + + +async def test_admin_can_read_pending_queue_and_advisor_cannot() -> None: + app = create_app() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://test", timeout=30 + ) as client: + admin = await _token(client, "admin_t", "88888888") + advisor = await _token(client, "advisor_t", "abc12345") + + admin_response = await client.get( + PENDING_PATH, headers={"Authorization": f"Bearer {admin}"} + ) + advisor_response = await client.get( + PENDING_PATH, headers={"Authorization": f"Bearer {advisor}"} + ) + + assert admin_response.status_code == 200, admin_response.text + items = admin_response.json()["data"] + assert isinstance(items, list) + + # 投顾不能读管理面队列(权限是 `product-recommendation:review` + admin) + assert advisor_response.status_code == 403, advisor_response.text + assert advisor_response.json()["error"]["code"] == "AGENT_PERMISSION_DENIED" + + +async def test_pending_items_carry_the_key_each_content_type_needs() -> None: + """推荐方案按 `content_id` 寻址、方案书按 `goal_no` —— 两者都要给全。""" + app = create_app() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://test", timeout=30 + ) as client: + admin = await _token(client, "admin_t", "88888888") + response = await client.get(PENDING_PATH, headers={"Authorization": f"Bearer {admin}"}) + + assert response.status_code == 200, response.text + for item in response.json()["data"]: + assert item["content_id"], "每条待审内容都必须有 content_id" + assert item["content_type"] in {BOOK_TYPE, RECOMMENDATION_TYPE} + assert item["review_status"] in {"pending", "pending_review"} + if item["content_type"] == BOOK_TYPE: + # 方案书的审核/发布端点(AD006/AD007)按 goal_no 寻址,缺了就没法调 + assert item["goal_no"], ( + f"方案书 content_id={item['content_id']} 没带 goal_no," + "管理员拿到列表也调不动审核端点" + ) + else: + assert item["goal_no"] is None, "推荐方案不该有 goal_no"