## 现象与根因
投顾工作台生成推荐方案后,草案停在 `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` 通过。
126 lines
5.2 KiB
Python
126 lines
5.2 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)
|
||
|
||
|
||
# ---- 投顾自助审核/发布(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)
|