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 @@
+
-
+