交付落点
- 新增 GET /api/v1/users/me/advisor-contents(客户读**自己**已发布方案):
「发送给客户」原先只改数据状态、客户端没有任何页面或接口能读到它
- 客户端新增「我的投顾方案」页与导航入口
可视化(投顾结果区与客户页**共用** common/advisor-plan-view.js,避免两处漂移)
- 净值折线图(带坐标轴与网格)、组合业绩等权合成曲线(含区间收益与最大回撤)、
资产配置环形图与图例、组合构成条
- 修 num(null)=0 的假 0:Number(null)/Number('') 会得 0,导致「没数据」被渲染成 0.00%;
现一律显示「--」。同理管理费/起投未维护时按没数据处理,不显示 0
- 涨跌口径为「涨红跌绿」(A 股习惯),由 CSS 变量 --plan-up / --plan-down 集中定义
推荐依据接入大模型(可选,失败即回退)
- 新增 AdvisorReasonService:**只改文案,不参与选品**(候选池与排序在它之前已固定)
- 输入只允许是已算出的真实参数(风险等级、排序得分、区间收益、最大回撤、期限与流动性)
- 命中收益承诺词(保本/保证收益/稳赚/无风险…)整条丢弃并回退规则文案
- 未启用 / 缺密钥 / 超时 / 解析失败一律回退,推荐主流程不因模型不可用而失败
- 前端标注来源(AI 生成 / 规则生成)
数据与权限
- 客户角色补齐:绑 customer 角色、补建缺失的账户与交易段权限码(9060-9065)
- 净值全量同步(20 只产品),行情同步脚本按 --codes 分块(全量一次会被超时终止)
测试
- 新增 tests/unit/service/test_advisor_reason_service.py(10 项,专测三条合规边界)
- 前端模块自检纳入 service-request-module;补「两处共用同一渲染」回归测试
731 lines
34 KiB
Python
731 lines
34 KiB
Python
"""Constraint-first recommendations for the exchange-traded simulation domain."""
|
||
|
||
import logging
|
||
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 (
|
||
ForbiddenAgentError,
|
||
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.fund import FundNavHistory
|
||
from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent
|
||
from app.repository.advisor_product_repository import (
|
||
AdvisorProductRepository,
|
||
AuthoritativeProductCandidate,
|
||
)
|
||
from app.service.advisor_reason_service import AdvisorReasonService
|
||
from app.service.api_transaction_service import ApiTransactionService
|
||
from app.service.authorization_service import AuthorizationService
|
||
from app.service.investment_goal_service import InvestmentGoalService
|
||
# 流动性要求的中文文案与投资目标线**同源**(`investment_goal_service._LIQUIDITY_LABELS`):
|
||
# 这里直接复用,避免两处各翻一遍、日后口径漂移。
|
||
from app.service.investment_goal_service import _LIQUIDITY_LABELS
|
||
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"
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
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)
|
||
]
|
||
# 「推荐依据」的 LLM 增强:把**已经算出来的真实参数**交给模型,写成客户读得懂的说明。
|
||
# 未启用 / 缺密钥 / 调用失败 / 未过合规守卫时,保留 `_view` 的确定性文案。
|
||
# 注意:模型只改文案,**不参与选品**(候选池与排序在它之前就已固定)。
|
||
products = await self._enhance_reasons(
|
||
products, selected, goal, authority.customer_risk_level
|
||
)
|
||
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
|
||
# 推荐依据要给出**这一只**的真实参数,而不是每只都同一句套话。
|
||
# 全部取自本次计算过程(硬约束、适当性等级、目标的期限/流动性、排序得分),
|
||
# 不做任何收益承诺 —— 文案里不出现"稳赚/保本"这类词。
|
||
liquidity_raw = str(goal.get("liquidity_requirement") or "")
|
||
liquidity_label = _LIQUIDITY_LABELS.get(liquidity_raw, liquidity_raw or "--")
|
||
reason = (
|
||
"已通过三项硬约束:场内可交易 / 权威适当性证据 / 基金合同快照;"
|
||
f"风险等级 {candidate.suitability.risk_level},与客户风险承受力匹配;"
|
||
f"按投资期限 {goal.get('investment_horizon_months')} 个月、"
|
||
f"流动性要求「{liquidity_label}」筛入;"
|
||
f"综合排序得分 {round(score, 4)}。"
|
||
)
|
||
return {
|
||
"rank": rank,
|
||
"product_code": product.product_code,
|
||
"product_name": product.product_name,
|
||
"product_category": product.product_category,
|
||
"reason": 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 _performance_snapshot(
|
||
self, product_ids: list[int]
|
||
) -> dict[int, dict[str, float | None]]:
|
||
"""每只产品的近 20/60 个交易日区间收益与近 60 日最大回撤(`fin_nav_history`)。
|
||
|
||
这些数字是「推荐依据」的**事实来源**:既给确定性文案,也给大模型当输入,
|
||
避免模型自己编业绩。表里没数据时对应值为 None(文案与提示词都显示「暂无」)。
|
||
"""
|
||
if not product_ids:
|
||
return {}
|
||
snapshot: dict[int, dict[str, float | None]] = {
|
||
pid: {"return_20d_pct": None, "return_60d_pct": None, "max_drawdown_60d_pct": None}
|
||
for pid in product_ids
|
||
}
|
||
async with self.session_factory() as session:
|
||
rows = (
|
||
await session.execute(
|
||
select(FundNavHistory.product_id, FundNavHistory.nav)
|
||
.where(FundNavHistory.product_id.in_(product_ids))
|
||
.order_by(FundNavHistory.product_id, FundNavHistory.nav_date.asc())
|
||
)
|
||
).all()
|
||
grouped: dict[int, list[float]] = {}
|
||
for product_id, nav in rows:
|
||
grouped.setdefault(int(product_id), []).append(float(nav))
|
||
for product_id, values in grouped.items():
|
||
snapshot[int(product_id)] = {
|
||
"return_20d_pct": _interval_return(values, 20),
|
||
"return_60d_pct": _interval_return(values, 60),
|
||
"max_drawdown_60d_pct": _max_drawdown_pct(values[-61:]),
|
||
}
|
||
return snapshot
|
||
|
||
async def _enhance_reasons(
|
||
self,
|
||
products: list[dict[str, object]],
|
||
selected: list[tuple[AuthoritativeProductCandidate, float]],
|
||
goal: dict[str, object],
|
||
customer_risk_level: int,
|
||
) -> list[dict[str, object]]:
|
||
"""给每只产品标 `reason_source`(llm / rule),并在可用时替换成 LLM 文案。"""
|
||
snapshot: dict[int, dict[str, float | None]] = {}
|
||
try:
|
||
snapshot = await self._performance_snapshot(
|
||
[int(candidate.product.id) for candidate, _ in selected]
|
||
)
|
||
except Exception: # noqa: BLE001 — 读不到历史不该影响出方案
|
||
logger.warning("推荐依据:历史净值读取失败,按「暂无」处理", exc_info=True)
|
||
|
||
prompt_products: list[dict[str, object]] = []
|
||
for index, product in enumerate(products):
|
||
product_id = int(selected[index][0].product.id) if index < len(selected) else None
|
||
facts = snapshot.get(product_id, {}) if product_id is not None else {}
|
||
product["performance"] = facts
|
||
prompt_products.append({**product, **facts})
|
||
|
||
liquidity_raw = str(goal.get("liquidity_requirement") or "")
|
||
generated = await AdvisorReasonService().enhance(
|
||
customer={
|
||
"risk_level": customer_risk_level,
|
||
"horizon_months": goal.get("investment_horizon_months"),
|
||
"liquidity": _LIQUIDITY_LABELS.get(liquidity_raw, liquidity_raw),
|
||
},
|
||
products=prompt_products,
|
||
)
|
||
for product in products:
|
||
code = str(product.get("product_code"))
|
||
if code in generated:
|
||
product["reason"] = generated[code]
|
||
product["reason_source"] = "llm"
|
||
else:
|
||
product["reason_source"] = "rule"
|
||
return products
|
||
|
||
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,
|
||
)
|
||
|
||
async def delete(
|
||
self, content_id: int, context: RequestContext, key: str | None
|
||
) -> dict[str, object]:
|
||
"""删除推荐方案(投顾工作台「历史方案记录」里的「删除」)。
|
||
|
||
## 为什么只允许删推荐方案
|
||
|
||
投资方案书(`BOOK_CONTENT_TYPE`)被 `advisor_investment_goal.goal_book_content_id`
|
||
以 `NO ACTION` 外键引用,且该列 **`NOT NULL`** —— 硬删方案书必然撞外键(1451)。
|
||
方案书有自己的 `goal_no` 生命周期,不走这里。
|
||
|
||
## 归属
|
||
|
||
复用 `_visible_customer_ids`(本人 + 名下归属客户),与 `published` / `history`
|
||
同一把尺子:不是自己能看的客户,方案也删不得。
|
||
"""
|
||
await AuthorizationService.require(context, "product-recommendation:delete")
|
||
|
||
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.customer_id not in self._visible_customer_ids(context):
|
||
raise ForbiddenAgentError("无权操作该客户的方案")
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
await session.delete(content)
|
||
# 删除也留痕:`interaction_audit` 与 `client_facing_content` 无外键,
|
||
# 方案没了审计仍在(合规要求「删了什么、谁删的」可追)。
|
||
session.add(
|
||
InteractionAudit(
|
||
actor_type="user",
|
||
actor_id=int(context.user_id),
|
||
target_customer_id=content.customer_id,
|
||
portal=context.portal,
|
||
action_type="advisor.recommendation_deleted",
|
||
detail={
|
||
"content_id": str(content_id),
|
||
"content_type": self.CONTENT_TYPE,
|
||
"trace_id": context.trace_id,
|
||
},
|
||
created_at=now,
|
||
)
|
||
)
|
||
await session.flush()
|
||
return {
|
||
"data": {"content_id": str(content_id), "status": "deleted"},
|
||
"meta": {"trace_id": context.trace_id},
|
||
}
|
||
|
||
return await ApiTransactionService().execute(
|
||
context,
|
||
f"advisor:recommendations:{content_id}:delete",
|
||
key,
|
||
{"delete": 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 my_published(self, context: RequestContext) -> dict[str, object]:
|
||
"""客户视角:**自己**已被审核发布的投顾交付物(`/api/v1/users/me/advisor-contents`)。
|
||
|
||
与 `published()`(投顾侧)的区别在**范围**:这里只看 `customer_id == 自己`,
|
||
不掺 `sys_customer_assignment` —— 那是投顾的归属概念,客户没有归属客户。
|
||
只返回 `published_at` 非空的:投顾「发送给客户」之前,客户看不到。
|
||
"""
|
||
await AuthorizationService.require(context, "product-recommendation:read:self")
|
||
customer_id = int(context.user_id)
|
||
async with self.session_factory() as session:
|
||
rows = list(
|
||
await session.scalars(
|
||
select(ClientFacingContent)
|
||
.where(
|
||
ClientFacingContent.customer_id == customer_id,
|
||
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(50)
|
||
)
|
||
)
|
||
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 history(self, context: RequestContext) -> dict[str, object]:
|
||
"""投顾本人的方案留档:本人 + 名下归属客户的**全部状态**方案与方案书。
|
||
|
||
与 `published()` 的唯一差别是**状态口径**:`published` 只给已发布(供客户看),
|
||
`history` 给全部(含 `pending_review`/`pending` 待审、`rejected` 已驳回),
|
||
供投顾在工作台回看"以前生成过什么"。归属过滤复用 `_visible_customer_ids`,
|
||
与 `published` 保持同一把尺子。
|
||
"""
|
||
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),
|
||
)
|
||
.order_by(ClientFacingContent.created_at.desc())
|
||
.limit(50)
|
||
)
|
||
)
|
||
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,
|
||
"reviewed_at": row.reviewed_at.isoformat() if row.reviewed_at else None,
|
||
"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
|
||
)
|
||
|
||
|
||
def _interval_return(values: list[float], trading_days: int) -> float | None:
|
||
"""近 N 个交易日的区间收益(%);数据不足或基准为 0 时返回 None。"""
|
||
if len(values) < 2:
|
||
return None
|
||
base = values[max(0, len(values) - 1 - trading_days)]
|
||
if not base:
|
||
return None
|
||
return (values[-1] - base) / base * 100
|
||
|
||
|
||
def _max_drawdown_pct(values: list[float]) -> float | None:
|
||
"""区间最大回撤(%,负值);数据不足返回 None。"""
|
||
if len(values) < 2:
|
||
return None
|
||
peak = values[0]
|
||
worst = 0.0
|
||
for value in values:
|
||
peak = max(peak, value)
|
||
if peak:
|
||
worst = min(worst, (value - peak) / peak * 100)
|
||
return worst
|