feat(advisor): 登记 17 个投顾端点并补管理员复核入口(A047 待审队列)

## 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。
This commit is contained in:
2026-09-14 00:17:46 +08:00
parent 373bcb2a68
commit de55c5c60c
7 changed files with 312 additions and 5 deletions
@@ -0,0 +1,85 @@
"""真实 MySQL:管理员待审队列(A047)与审核链路的可达性。
## 为什么单独守这一条
`review` / `publish` 都要求调用方**先拿到键** —— 推荐方案是 `content_id`、
投资方案书是 `goal_no`。在 A047 之前**没有任何端点能列出待审内容**,
管理员拿不到键,于是投顾生成的东西永远停在待审状态、没有人能推进。
本文件守住三件事:
1. 管理员能读到待审队列;
2. **投顾读不到**(这条队列是管理面的,权限比 `published` 更严);
3. **方案书必须带 `goal_no`** —— 否则前端拿到列表也调不动 AD006/AD007。
"""
from __future__ import annotations
import httpx
import pytest
from app.main import create_app
pytestmark = pytest.mark.integration
PENDING_PATH = "/api/v1/admin/advisor/pending-contents"
BOOK_TYPE = "investment_goal_book"
RECOMMENDATION_TYPE = "advisor_recommendation_plan"
async def _token(client: httpx.AsyncClient, username: str, password: str) -> str:
response = await client.post(
"/api/v1/auth/tokens", json={"username": username, "password": password}
)
assert response.status_code == 200, response.text
return response.json()["data"]["access_token"]
async def test_admin_can_read_pending_queue_and_advisor_cannot() -> None:
app = create_app()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test", timeout=30
) as client:
admin = await _token(client, "admin_t", "88888888")
advisor = await _token(client, "advisor_t", "abc12345")
admin_response = await client.get(
PENDING_PATH, headers={"Authorization": f"Bearer {admin}"}
)
advisor_response = await client.get(
PENDING_PATH, headers={"Authorization": f"Bearer {advisor}"}
)
assert admin_response.status_code == 200, admin_response.text
items = admin_response.json()["data"]
assert isinstance(items, list)
# 投顾不能读管理面队列(权限是 `product-recommendation:review` + admin)
assert advisor_response.status_code == 403, advisor_response.text
assert advisor_response.json()["error"]["code"] == "AGENT_PERMISSION_DENIED"
async def test_pending_items_carry_the_key_each_content_type_needs() -> None:
"""推荐方案按 `content_id` 寻址、方案书按 `goal_no` —— 两者都要给全。"""
app = create_app()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test", timeout=30
) as client:
admin = await _token(client, "admin_t", "88888888")
response = await client.get(PENDING_PATH, headers={"Authorization": f"Bearer {admin}"})
assert response.status_code == 200, response.text
for item in response.json()["data"]:
assert item["content_id"], "每条待审内容都必须有 content_id"
assert item["content_type"] in {BOOK_TYPE, RECOMMENDATION_TYPE}
assert item["review_status"] in {"pending", "pending_review"}
if item["content_type"] == BOOK_TYPE:
# 方案书的审核/发布端点(AD006/AD007)按 goal_no 寻址,缺了就没法调
assert item["goal_no"], (
f"方案书 content_id={item['content_id']} 没带 goal_no,"
"管理员拿到列表也调不动审核端点"
)
else:
assert item["goal_no"] is None, "推荐方案不该有 goal_no"