Files
group_fqcd_jr/app/service/product_recommendation_service.py
T
lzf_0626 467d2b5169 投顾可自助审核/发布自己生成的推荐方案(原先只有管理员能推进草案)
## 现象与根因

投顾工作台生成推荐方案后,草案停在 `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` 通过。
2026-09-14 23:27:44 +08:00

471 lines
22 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]:
# 代客:payload.customer_id 指定被分析客户;留空则以登录用户自身为对象(原行为)。
customer_id = payload.customer_id or int(context.user_id)
if customer_id == int(context.user_id):
await AuthorizationService.require(context, "product-recommendation:generate:self")
else:
await AuthorizationService.require_customer_scope(
context, "product-recommendation:generate:customer", customer_id
)
if self.enforce_profile_governance:
await ProfileGovernanceService().require_operable(customer_id)
authority = await SuitabilityService().authority_for_customer(customer_id)
if authority.customer_risk_level is None:
return {"status": "profile_required"}
goal = await InvestmentGoalService().current_for_customer(customer_id, 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(customer_id)
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=customer_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=customer_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:{customer_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, customer_id: int) -> dict[str, object]:
if self.relationship_service is None:
return {"degraded": True, "reason": "graph_not_configured"}
return await self.relationship_service.portfolio_industry_context(customer_id)
async def review(
self,
content_id: int,
decision: str,
comment: str,
context: RequestContext,
key: str | None,
) -> dict[str, object]:
# 审核权**只按权限码**,不再额外要求 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)
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]:
# 同上:发布权按权限码判定(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)
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
)