Files
group_fqcd_jr/app/api/controllers/recommendations.py
T
lzf_0626 467d2b5169 投顾可自助审核/发布自己生成的推荐方案(原先只有管理员能推进草案)
## 现象与根因

投顾工作台生成推荐方案后,草案停在 `pending_review` 且投顾无法推进:

- 服务层 `review` / `publish` 都带 **`admin=True` 角色闸门**
  (`product_recommendation_service.py:286/317`),即使投顾角色**已经持有**
  `product-recommendation:review` / `:publish` 两个权限码也一律 403;
- 审核/发布端点只注册在 **admin 路由**下(`/api/v1/admin/advisor/...`),
  投顾侧根本没有对应入口;
- 投顾工作台也没有审核/发布按钮(`published-module.js` 原注释即写着
  "发布动作要求管理员,投顾侧只读")。

于是业务上"让投顾自己审核"完全做不到,必须切管理员账号。

## 修法(三处配套,安全边界保留)

1. `app/service/product_recommendation_service.py`
   - `review` / `publish` 去掉 `admin=True`,**只按权限码判定**
     (`product-recommendation:review` / `:publish`,目前仅 advisor 与 admin 持有);
   - `reviewer_user_id` 照旧如实落库,审计可追;
   - 注释写明:若要回到"四眼原则/管理员专属",把 `admin=True` 加回即可。
2. `app/api/controllers/recommendations.py`
   - 新增投顾侧路由 `POST /api/v1/advisor/recommendations/{id}/reviews`
     与 `.../publications`(与 admin 路由调用同一服务方法)。
3. 前端
   - `common/api-client.js`:注册 `ADVISOR_REVIEW_RECOMMENDATION` /
     `ADVISOR_PUBLISH_RECOMMENDATION`;
   - `employee-advisor/dashboard/actions-module.js`:结果区在拿到 `content_id` 后
     给出「审核通过 / 驳回 / 发布给客户」按钮(结果区是 `innerHTML` 重建的,
     所以每次渲染后重新绑定);审核通过后就地换成「发布给客户」;
   - `published-module.js`:监听 `advisor:published-refresh`,发布成功后列表自动刷新。

## 未放宽的部分(有意保留)

- **管理面复核队列** `GET /api/v1/admin/advisor/pending-contents` 仍为
  `admin=True` 专属 —— `tests/integration/test_advisor_review_queue_mysql.py`
  里"投顾读不到该队列"的断言**未改动**;
- 客户/风控/运营角色不持有这两个权限码,因此不受影响。

## 验证(真实 HTTP,9020 身份)

```
① 生成推荐方案(客户 9001)→ content_id=19, pending_review
② 投顾自助审核通过          → HTTP 200 status=approved     (改前 403)
③ 投顾自助发布              → HTTP 200 status=published
④ 已发布列表                → 含 id=19 ✅
```

新增回归测试 `test_advisor_can_review_and_publish_own_recommendation`
(客户缺测评/目标时 `pytest.skip` 并说明是数据前置,不误判为权限失败)。

## 门禁

- `pytest tests/unit tests/contract` → 1458 passed;
- `pytest tests/integration` → 111 passed + 1 例
  `test_worker_runtime_mysql::...repeat[False]` 失败,**经复跑确认是 AGENTS.md 记载的
  "常驻 Worker 抢队列",停掉常驻 Worker 后该用例 2 passed**,与本次改动无关;
- `ruff` 干净;三个 JS 文件 `node --check` 通过。
2026-09-14 23:27:44 +08:00

126 lines
5.2 KiB
Python
Raw 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)],
)
@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)
# ---- 投顾自助审核/发布(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)
@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)