Files
group_fqcd_jr/app/service/product_recommendation_service.py
T
lzf_0626 de55c5c60c 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。
2026-09-14 00:17:46 +08:00

454 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Constraint-first recommendations for the exchange-traded simulation domain."""
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.core.errors import GenericResourceNotFoundError, InvalidStateError
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 AdvisorInvestmentGoal, ClientFacingContent
from app.repository.advisor_product_repository import (
AdvisorProductRepository,
AuthoritativeProductCandidate,
)
from app.service.api_transaction_service import ApiTransactionService
from app.service.authorization_service import AuthorizationService
from app.service.investment_goal_service import InvestmentGoalService
from app.service.product_governance_monitor_service import SALES_INSTITUTION
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"
#: 面向客户展示的投顾内容类型。**方案书(goal book)是本项目投顾交付的主产物**,
#: 由 `InvestmentGoalService` 写入同一张 `client_facing_content` 表,靠 `content_type`
#: 区分。投顾工作台只认 recommendation 时永远为空 —— 因为投顾给客户交付的是方案书。
CLIENT_CONTENT_TYPES: tuple[str, ...] = ("advisor_recommendation_plan", "investment_goal_book")
#: 两类内容的"已发布"在库里取值不同:recommendation 审核通过后置 `approved`
#: (`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,
*,
session_factory: Callable[[], Any] = SessionFactory,
relationship_service: RelationshipService | None = None,
enforce_profile_governance: bool = False,
) -> None:
self.session_factory = session_factory
self.relationship_service = relationship_service or RelationshipService(
Neo4jGraphDriver(get_settings())
)
self.enforce_profile_governance = enforce_profile_governance
async def generate(
self, payload: ProductRecommendationQuery, context: RequestContext, key: str | None
) -> dict[str, object]:
await AuthorizationService.require(context, "product-recommendation:generate:self")
if self.enforce_profile_governance:
await ProfileGovernanceService().require_operable(int(context.user_id))
authority = await SuitabilityService().authority_for_customer(int(context.user_id))
if authority.customer_risk_level is None:
return {"status": "profile_required"}
goal = await InvestmentGoalService().current_for_agent(context)
if goal is None:
return {"status": "investment_goal_required"}
candidates, excluded = await self._candidates(
authority.customer_risk_level, str(goal["liquidity_requirement"])
)
horizon = goal.get("investment_horizon_months")
if not isinstance(horizon, int):
return {"status": "recommendation_input_invalid"}
ranked = self._rank(
candidates, authority.customer_risk_level, horizon
)
selected = ranked[: payload.limit]
excluded.extend(self._ranking_exclusions(ranked[payload.limit :], payload.limit))
graph_context = await self._graph_context(context)
products = [
self._view(item, index, goal, graph_context)
for index, item in enumerate(selected, start=1)
]
plan = {
"document_type": "advisor_recommendation_plan",
"document_version": "1.0",
"products": products,
"excluded_candidates": excluded,
"selection_summary": {
"candidate_count": len(candidates) + len(excluded),
"selected_count": len(products),
"excluded_count": len(excluded),
},
"graph_context": graph_context,
"disclosures": [
"推荐结果仅供场内基金模拟交易分析,不构成交易指令。",
"历史数据和风险等级不代表未来收益,收益目标不构成承诺。",
"推荐方案须经审核发布后方可对客户展示。",
],
}
if key is None:
return {"status": "ready", **plan, "analysis_only": True}
async def operation(session: Any) -> dict[str, object]:
now = datetime.now(UTC).replace(tzinfo=None)
content = ClientFacingContent(
customer_id=int(context.user_id),
content_type=self.CONTENT_TYPE,
draft_content=plan,
generated_by_portal=context.portal,
review_status="pending_review",
reviewer_user_id=None,
reviewed_at=None,
published_at=None,
created_at=now,
updated_at=now,
)
session.add(content)
session.add(
InteractionAudit(
actor_type="user",
actor_id=int(context.user_id),
target_customer_id=int(context.user_id),
portal=context.portal,
action_type="advisor.recommendation_created",
detail={
"content_type": self.CONTENT_TYPE,
"status": "pending_review",
"trace_id": context.trace_id,
},
created_at=now,
)
)
await session.flush()
return {
"data": {
"content_id": str(content.id),
"status": content.review_status,
"plan": plan,
},
"meta": {"trace_id": context.trace_id},
}
return await ApiTransactionService().execute(
context,
f"advisor:recommendations:{context.user_id}",
key,
payload.model_dump(mode="json"),
operation,
)
async def _candidates(
self, customer_risk_level: int, liquidity_requirement: str
) -> tuple[list[AuthoritativeProductCandidate], list[dict[str, object]]]:
async with self.session_factory() as session:
candidates = await AdvisorProductRepository(session).authoritative_tradable_products(
datetime.now(UTC).replace(tzinfo=None),
sales_institution=SALES_INSTITUTION,
liquidity_requirement=liquidity_requirement,
limit=50,
)
return AdvisorProductRepository.hard_suitability_filter(candidates, customer_risk_level)
@staticmethod
def _rank(
candidates: list[AuthoritativeProductCandidate], risk: int, horizon: int
) -> list[tuple[AuthoritativeProductCandidate, float]]:
def score(candidate: AuthoritativeProductCandidate) -> float:
level = int(candidate.suitability.risk_level.removeprefix("R"))
risk_score = 1 - abs(risk - level) / 4
liquidity = candidate.liquidity
if liquidity is None or liquidity.average_daily_turnover_amount is None:
liquidity_score = 0.5
else:
liquidity_score = min(
1.0, float(liquidity.average_daily_turnover_amount / 10_000_000)
)
term_score = (
0.8
if horizon >= 36 and candidate.product.product_category in {"ETF", "LOF"}
else 0.6
)
return 0.55 * risk_score + 0.25 * liquidity_score + 0.20 * term_score
return sorted(
((candidate, score(candidate)) for candidate in candidates),
key=lambda item: (-item[1], item[0].product.product_code),
)
@staticmethod
def _ranking_exclusions(
ranked: list[tuple[AuthoritativeProductCandidate, float]], limit: int
) -> list[dict[str, object]]:
return [
{
"product_code": candidate.product.product_code,
"product_name": candidate.product.product_name,
"stage": "ranking",
"reason_code": "RANKED_BELOW_SELECTION_LIMIT",
"reason": "产品通过硬性约束但排序低于本次选择数量。",
"ranking_score": round(score, 4),
"selection_limit": limit,
}
for candidate, score in ranked
]
@staticmethod
def _view(
item: tuple[AuthoritativeProductCandidate, float],
rank: int,
goal: dict[str, object],
graph_context: dict[str, object],
) -> dict[str, object]:
candidate, score = item
product = candidate.product
contract = candidate.contract
return {
"rank": rank,
"product_code": product.product_code,
"product_name": product.product_name,
"product_category": product.product_category,
"reason": "该产品已通过场内可交易、权威适当性和合同证据校验,"
"并与已确认投资目标的期限和流动性要求相匹配。",
"score": round(score, 4),
"recommendation_evidence_card": {
"card_version": "1.0",
"hard_constraints": [
"exchange_traded",
"suitability_verified",
"contract_verified",
],
"suitability": {
"risk_level": candidate.suitability.risk_level,
"source_url": candidate.suitability.source_url,
"document_title": candidate.suitability.document_title,
},
"contract": {
"fund_type": contract.fund_type,
"source_url": contract.source_url,
"document_title": contract.document_title,
},
"liquidity": {
"status": candidate.liquidity.status if candidate.liquidity else "unknown",
"average_daily_turnover_amount": str(
candidate.liquidity.average_daily_turnover_amount
)
if candidate.liquidity
and candidate.liquidity.average_daily_turnover_amount is not None
else None,
},
"goal_constraints": {
"liquidity_requirement": goal["liquidity_requirement"],
"investment_horizon_months": goal["investment_horizon_months"],
},
"graph_status": "degraded" if graph_context.get("degraded") else "available",
},
}
async def _graph_context(self, context: RequestContext) -> dict[str, object]:
if self.relationship_service is None:
return {"degraded": True, "reason": "graph_not_configured"}
return await self.relationship_service.portfolio_industry_context(int(context.user_id))
async def review(
self,
content_id: int,
decision: str,
comment: str,
context: RequestContext,
key: str | None,
) -> dict[str, object]:
await AuthorizationService.require(context, "product-recommendation:review", admin=True)
async def operation(session: Any) -> dict[str, object]:
content = await session.get(ClientFacingContent, content_id, with_for_update=True)
if content is None or content.content_type != self.CONTENT_TYPE:
raise GenericResourceNotFoundError("推荐方案不存在")
if content.review_status != "pending_review":
raise InvalidStateError("推荐方案当前不能审核")
now = datetime.now(UTC).replace(tzinfo=None)
content.review_status = "approved" if decision == "approved" else "rejected"
content.reviewer_user_id = int(context.user_id)
content.reviewed_at = now
content.updated_at = now
content.draft_content = {**content.draft_content, "review_comment": comment}
await session.flush()
return {
"data": {"content_id": str(content.id), "status": content.review_status},
"meta": {"trace_id": context.trace_id},
}
return await ApiTransactionService().execute(
context,
f"advisor:recommendations:{content_id}:review",
key,
{"decision": decision, "comment": comment},
operation,
)
async def publish(
self, content_id: int, context: RequestContext, key: str | None
) -> dict[str, object]:
await AuthorizationService.require(context, "product-recommendation:publish", admin=True)
async def operation(session: Any) -> dict[str, object]:
content = await session.get(ClientFacingContent, content_id, with_for_update=True)
if content is None or content.content_type != self.CONTENT_TYPE:
raise GenericResourceNotFoundError("推荐方案不存在")
if content.review_status != "approved":
raise InvalidStateError("推荐方案审核通过后才能发布")
content.review_status = "approved"
content.published_at = datetime.now(UTC).replace(tzinfo=None)
content.updated_at = content.published_at
await session.flush()
return {
"data": {"content_id": str(content.id), "status": "published"},
"meta": {"trace_id": context.trace_id},
}
return await ApiTransactionService().execute(
context,
f"advisor:recommendations:{content_id}:publish",
key,
{"publish": True},
operation,
)
@staticmethod
def _visible_customer_ids(context: RequestContext) -> tuple[int, ...]:
"""可查看的客户 id:本人 + 名下归属客户。
为什么不用 `data_scope`:投顾/运营因为持有 `promotion:*` 这类 all 级权限,
`IdentityService` 会把**整个身份**的 scope 抬到 `all`(`identity_repository` 取各授权
scope 的最大值)。按 scope 判定会让他们看到全部客户的方案,属过度开放。
归属关系来自 `sys_customer_assignment`(逐条授权,且带 assigned_at/unassigned_at
时间窗校验),比 scope 更窄,也更贴合"投顾只看自己服务的客户"这个业务口径。
"""
ids = {str(context.user_id), *(str(item) for item in context.customer_ids)}
return tuple(sorted({int(item) for item in ids if item.strip().isdigit()}))
async def published(self, context: RequestContext) -> dict[str, object]:
await AuthorizationService.require(context, "product-recommendation:read:self")
customer_ids = self._visible_customer_ids(context)
if not customer_ids:
return {"data": [], "meta": {"trace_id": context.trace_id}}
async with self.session_factory() as session:
rows = list(
await session.scalars(
select(ClientFacingContent)
.where(
ClientFacingContent.customer_id.in_(customer_ids),
ClientFacingContent.content_type.in_(self.CLIENT_CONTENT_TYPES),
ClientFacingContent.review_status.in_(self.PUBLISHED_STATES),
ClientFacingContent.published_at.is_not(None),
)
.order_by(ClientFacingContent.published_at.desc())
.limit(20)
)
)
return {
"data": [
{
"content_id": str(row.id),
"customer_id": str(row.customer_id),
"content_type": row.content_type,
"plan": row.draft_content,
"published_at": row.published_at.isoformat() if row.published_at else None,
}
for row in rows
],
"meta": {"trace_id": context.trace_id},
}
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]:
return await ProductRecommendationService(enforce_profile_governance=True).generate(
arguments, context, None
)