2026-09-11 15:29:20 +08:00
|
|
|
|
"""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
|
2026-09-11 20:27:56 +08:00
|
|
|
|
from app.service.advisor_rollout_service import enforce_advisor_rollout
|
2026-09-11 15:29:20 +08:00
|
|
|
|
from app.service.product_recommendation_service import ProductRecommendationService
|
|
|
|
|
|
|
|
|
|
|
|
advisor_router = APIRouter(
|
|
|
|
|
|
prefix="/api/v1/advisor",
|
|
|
|
|
|
tags=["advisor-recommendations"],
|
2026-09-11 20:27:56 +08:00
|
|
|
|
dependencies=[Depends(enforce_rate_limit), Depends(enforce_advisor_rollout)],
|
2026-09-11 15:29:20 +08:00
|
|
|
|
)
|
|
|
|
|
|
admin_router = APIRouter(
|
|
|
|
|
|
prefix="/api/v1/admin",
|
|
|
|
|
|
tags=["platform-admin"],
|
|
|
|
|
|
dependencies=[Depends(enforce_rate_limit)],
|
|
|
|
|
|
)
|
2026-09-16 18:17:47 +08:00
|
|
|
|
#: 客户自助路由:客户看**自己**已发布的投顾交付物。
|
|
|
|
|
|
#: 前缀挂在 `/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)],
|
|
|
|
|
|
)
|
2026-09-11 15:29:20 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
|
)
|
2026-09-11 15:29:20 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-16 18:17:47 +08:00
|
|
|
|
@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 23:27:44 +08:00
|
|
|
|
# ---- 投顾自助审核/发布(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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-16 18:17:47 +08:00
|
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 00:17:46 +08:00
|
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 20:27:56 +08:00
|
|
|
|
@admin_router.post(
|
|
|
|
|
|
"/advisor/recommendations/{content_id}/reviews",
|
|
|
|
|
|
dependencies=[Depends(enforce_advisor_rollout)],
|
|
|
|
|
|
)
|
2026-09-11 15:29:20 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 20:27:56 +08:00
|
|
|
|
@admin_router.post(
|
|
|
|
|
|
"/advisor/recommendations/{content_id}/publications",
|
|
|
|
|
|
dependencies=[Depends(enforce_advisor_rollout)],
|
|
|
|
|
|
)
|
2026-09-11 15:29:20 +08:00
|
|
|
|
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)
|
2026-09-16 18:17:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 客户侧:接收投顾交付物 ------------------------------------------------
|
|
|
|
|
|
#
|
|
|
|
|
|
# 「发送给客户」此前**没有落点** —— 投顾发布后只是把 `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)
|