feat(advisor): 登记 17 个投顾端点并补管理员复核入口(A047 待审队列)

## 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。
This commit is contained in:
2026-09-14 00:17:46 +08:00
parent 373bcb2a68
commit de55c5c60c
7 changed files with 312 additions and 5 deletions
+16
View File
@@ -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)],
+76 -1
View File
@@ -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]:
+5
View File
@@ -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' },
});
@@ -21,6 +21,7 @@
<button class="operations-tab" type="button" role="tab" aria-selected="false" data-admin-tab="audit">审计记录</button>
<button class="operations-tab" type="button" role="tab" aria-selected="false" data-admin-tab="handover">转人工工单</button>
<button class="operations-tab" type="button" role="tab" aria-selected="false" data-admin-tab="candidates">画像候选</button>
<button class="operations-tab" type="button" role="tab" aria-selected="false" data-admin-tab="advisor">投顾复核</button>
</div>
<section class="operations-view admin-view" data-admin-view="rbac">
<div class="admin-split">
@@ -35,10 +36,11 @@
<section class="operations-view admin-view" data-admin-view="audit" hidden><div class="panel__header"><h2 class="panel__title">最近审计记录</h2><button class="button table-action" type="button" data-reload-audit>刷新</button></div><div data-audit-table></div></section>
<section class="operations-view admin-view" data-admin-view="handover" hidden><div class="panel__header"><h2 class="panel__title">客服转人工工单</h2><span class="section-heading__meta">仅展示二次脱敏摘要</span></div><div data-handover-table></div></section>
<section class="operations-view admin-view" data-admin-view="candidates" hidden><div class="panel__header"><h2 class="panel__title">客户画像候选</h2><span class="section-heading__meta">审核后方可进入正式记忆</span></div><div data-candidate-table></div></section>
<section class="operations-view admin-view" data-admin-view="advisor" hidden><div class="panel__header"><h2 class="panel__title">待审投顾内容</h2><span class="section-heading__meta">推荐方案与投资方案书;审核通过后再发布</span><button class="button table-action" type="button" data-reload-advisor>刷新</button></div><div data-advisor-table></div></section>
</article>
</main>
<dialog class="operations-dialog" data-admin-detail><div class="operations-dialog__header"><h2 data-detail-title>详情</h2><button class="button icon-button" type="button" data-close-detail aria-label="关闭">×</button></div><div class="operations-dialog__body" data-detail-body></div><div class="operations-dialog__footer"><button class="button" type="button" data-close-detail>关闭</button></div></dialog>
<dialog class="operations-dialog" data-admin-action><form data-admin-action-form><div class="operations-dialog__header"><h2 data-admin-action-title>确认操作</h2><button class="button icon-button" type="button" data-close-admin-action aria-label="关闭">×</button></div><div class="operations-dialog__body"><p class="admin-action-copy" data-admin-action-copy></p><label class="form-field" data-admin-comment-field hidden><span class="form-field__label">审核意见</span><textarea class="form-field__input admin-comment" name="comment" maxlength="1000"></textarea></label><div class="form-alert" data-admin-action-alert></div></div><div class="operations-dialog__footer"><button class="button" type="button" data-close-admin-action>取消</button><button class="button button--primary" type="submit">确认提交</button></div></form></dialog>
<script type="module" src="/static/portal/employee-console/workspace/workspace.js?v=20260913"></script>
<script type="module" src="/static/portal/employee-console/workspace/workspace.js?v=20260913-2"></script>
</body>
</html>
@@ -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) => `<div class="admin-release-actions"><button class="button table-action" type="button" data-advisor-action="approve" data-content-id="${item.content_id}">审核通过</button><button class="button button--danger table-action" type="button" data-advisor-action="reject" data-content-id="${item.content_id}">驳回</button><button class="button table-action" type="button" data-advisor-action="publish" data-content-id="${item.content_id}">发布</button></div>`,
);
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 }, () => '<div class="skeleton"></div>').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();
}