## 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。
91 lines
3.4 KiB
Python
91 lines
3.4 KiB
Python
"""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)],
|
|
)
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@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)
|