Files

175 lines
7.7 KiB
Python
Raw Permalink Normal View History

"""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]:
2026-09-11 16:42:31 +08:00
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)