Files
group_fqcd_jr/app/api/controllers/recommendations.py
Windows 74b7d00dff feat(投顾): 方案交付落点、可视化图表与推荐依据的大模型增强
交付落点
- 新增 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;补「两处共用同一渲染」回归测试
2026-09-16 18:17:47 +08:00

175 lines
7.7 KiB
Python
Raw Permalink 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.
"""Recommendation generation and reviewed publication endpoints."""
from typing import Any
from fastapi import APIRouter, Depends, Header, Path
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.rate_limit import enforce_rate_limit
from app.core.contracts import RequestContext
from app.core.product_recommendation_contracts import ProductRecommendationQuery
from app.service.advisor_rollout_service import enforce_advisor_rollout
from app.service.product_recommendation_service import ProductRecommendationService
advisor_router = APIRouter(
prefix="/api/v1/advisor",
tags=["advisor-recommendations"],
dependencies=[Depends(enforce_rate_limit), Depends(enforce_advisor_rollout)],
)
admin_router = APIRouter(
prefix="/api/v1/admin",
tags=["platform-admin"],
dependencies=[Depends(enforce_rate_limit)],
)
#: 客户自助路由:客户看**自己**已发布的投顾交付物。
#: 前缀挂在 `/api/v1/users/me` 下,与 §T 段(交易)同一约定。
#: **故意不挂 `enforce_advisor_rollout`** —— 那是投顾业务的灰度闸门(按
#: `sys_customer_assignment` 归属命中白名单),客户看自己的交付物不该被它拦下。
client_router = APIRouter(
prefix="/api/v1/users/me",
tags=["advisor-deliveries"],
dependencies=[Depends(enforce_rate_limit)],
)
@advisor_router.post("/recommendations")
async def generate_recommendation(
payload: ProductRecommendationQuery,
context: RequestContext = Depends(build_request_context), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
return await ProductRecommendationService(enforce_profile_governance=True).generate(
payload, context, key
)
@advisor_router.get("/recommendations/published")
async def published_recommendations(
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, object]:
return await ProductRecommendationService().published(context)
@advisor_router.get("/recommendations/history")
async def recommendation_history(
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, object]:
"""历史方案留档:本人 + 名下客户的**全部状态**方案与方案书。
与 `/recommendations/published` 分开的原因:published 只给"已发布、对客户可见"的内容,
投顾刚生成、还在待审的草案不在其中。历史记录面板要的是"以前生成过什么",
所以这里返回全部状态,按生成时间倒序。
"""
return await ProductRecommendationService().history(context)
# ---- 投顾自助审核/发布(2026-09-14 新增)------------------------------------
#
# 为什么要有这两个**投顾侧**路由:审核/发布原先只在 `/api/v1/admin/advisor/...`
# 下、且服务层还有 `admin=True` 角色闸门 —— 于是投顾生成完草案后**无法自行推进**,
# 草案永远停在 `pending_review`,必须切到管理员账号才能审。业务要求投顾能审自己的方案。
#
# 与 admin 路由的关系:两者调用**同一个服务方法**,管理面复核队列
# (`GET /api/v1/admin/advisor/pending-contents`)仍保持 admin 专属、未放宽。
@advisor_router.post("/recommendations/{content_id}/reviews")
async def advisor_review_recommendation(
payload: dict[str, Any],
content_id: int = Path(gt=0),
context: RequestContext = Depends(build_request_context), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
decision = payload.get("decision")
if decision not in {"approved", "rejected"}:
from app.core.errors import ValidationAgentError
raise ValidationAgentError("decision 必须为 approved 或 rejected")
comment = payload.get("comment", "")
if not isinstance(comment, str):
raise ValueError("comment must be a string")
return await ProductRecommendationService().review(content_id, decision, comment, context, key)
@advisor_router.post("/recommendations/{content_id}/publications")
async def advisor_publish_recommendation(
content_id: int = Path(gt=0),
context: RequestContext = Depends(build_request_context), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
return await ProductRecommendationService().publish(content_id, context, key)
@advisor_router.delete("/recommendations/{content_id}")
async def advisor_delete_recommendation(
content_id: int = Path(gt=0),
context: RequestContext = Depends(build_request_context), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
"""删除推荐方案(历史记录里的「删除」按钮)。
仅推荐方案可删:投资方案书被 `advisor_investment_goal.goal_book_content_id`
(`NO ACTION` 外键、`NOT NULL`)引用,硬删会撞外键 —— 方案书走自己的生命周期。
"""
return await ProductRecommendationService().delete(content_id, context, key)
@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)],
)
async def review_recommendation(
payload: dict[str, Any],
content_id: int = Path(gt=0),
context: RequestContext = Depends(build_request_context), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
decision = payload.get("decision")
if decision not in {"approved", "rejected"}:
from app.core.errors import ValidationAgentError
raise ValidationAgentError("decision 必须为 approved 或 rejected")
comment = payload.get("comment", "")
if not isinstance(comment, str):
raise ValueError("comment must be a string")
return await ProductRecommendationService().review(content_id, decision, comment, context, key)
@admin_router.post(
"/advisor/recommendations/{content_id}/publications",
dependencies=[Depends(enforce_advisor_rollout)],
)
async def publish_recommendation(
content_id: int = Path(gt=0),
context: RequestContext = Depends(build_request_context), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
return await ProductRecommendationService().publish(content_id, context, key)
# ---- 客户侧:接收投顾交付物 ------------------------------------------------
#
# 「发送给客户」此前**没有落点** —— 投顾发布后只是把 `published_at` 置上,
# 客户端门户没有任何页面/接口能读到它(客户"可见"只体现在数据口径上)。
# 这条路由补上落点:客户登录后看**自己**已发布的推荐方案与方案书。
@client_router.get("/advisor-contents")
async def my_advisor_contents(
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, object]:
"""我的投顾方案:本人已审核发布的投顾交付物(按发布时间倒序,最多 50 条)。"""
return await ProductRecommendationService().my_published(context)