合并yy并同步远程qyqy_develop

This commit is contained in:
2026-09-14 01:11:07 +08:00
102 changed files with 7281 additions and 503 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"
@@ -114,3 +114,62 @@ async def test_activation_state_machine_feeds_runtime_intent_config() -> None:
{"agent": agent_type},
)
await session.execute(text("DELETE FROM sys_user WHERE id=:id"), {"id": actor})
async def test_activating_one_intent_does_not_archive_sibling_intents() -> None:
"""激活一个意图**不得**归档同 Agent 的其他意图。
唯一键是生成列 `active_key = concat(agent_type, ':', intent_code)`,
即同一 `agent_type` 下**不同意图码本就允许并存**(风控的 4 个意图就是并存的)。
这里曾经按 `agent_type` 过滤旧 active 版本去归档,于是激活 `general` 会把
`risk_overview` / `risk_search` / `risk_evidence` 一并归档:风控运行期只剩 1 条
active 意图,问"查看当前风险概览"被分到 `general`,**且没有任何报错**。
上一个用例恰好盖不住这个缺陷 —— 它建的第二个意图始终停留在 draft、从未激活过。
"""
agent_type = f"it_intent_multi_{uuid.uuid4().hex[:12]}"
actor = uuid.uuid4().int % 10**12 + 10**15
async with SessionFactory() as session, session.begin():
await session.execute(text("""
INSERT INTO sys_user
(id,user_no,username,password_hash,user_type,professional_investor_status,
fund_account_status,status,created_at,updated_at)
VALUES (:id,:name,:name,'test-only','员工','未申请','未开户','正常',
UTC_TIMESTAMP(),UTC_TIMESTAMP())
"""), {"id": actor, "name": f"it-intent-multi-{actor}"})
try:
context = RequestContext(
user_id=str(actor), trace_id="it-intent-multi", roles=("admin",)
)
service = AdminService()
# 两个**不同意图码**依次走完 draft -> approved -> active
for code in ("alpha", "beta"):
async with SessionFactory() as session:
repo = PlatformRepository(session)
created = await repo.create("agent_intent_config", {
"agent_type": agent_type, "intent_code": code,
"intent_name": f"{code} 意图", "description": f"{code} 描述",
"examples": [f"{code} 示例"],
"confidence_threshold": Decimal("0.6500"), "version": 1,
"created_by": actor,
})
approved = await service._transition(
repo, "agent-intent-configs", created, "reviews",
{"decision": "approved"}, context
)
await service._transition(
repo, "agent-intent-configs", approved, "activations", {}, context
)
await session.commit()
# 关键断言:激活 beta 之后 alpha **仍然 active**,两个都要能被运行期读到
entries = await load_active_intent_configs(agent_type)
assert sorted(entry.intent_code for entry in entries) == ["alpha", "beta"]
finally:
async with SessionFactory() as session, session.begin():
await session.execute(
text("DELETE FROM agent_intent_config WHERE agent_type=:agent"),
{"agent": agent_type},
)
await session.execute(text("DELETE FROM sys_user WHERE id=:id"), {"id": actor})
@@ -0,0 +1,129 @@
"""真实 MySQL:访客令牌必须能读到公开产品列表。
## 为什么单独守这一条
访客令牌**不带任何权限码**(`app/core/security.py` 只给它 `roles=("visitor",)`),
P001 靠的是"要求令牌但不校验权限"的口径。若有人照管理面接口的样子给它加上权限码,
访客产品页(首页推荐、产品列表、产品详情)会**整体 401**,
而前端只会显示"数据暂时不可用",极难联想到是权限口径问题。
本文件同时守住**载荷边界**:公开端点不得混入任何账户/客户字段。
"""
from __future__ import annotations
import httpx
import pytest
from app.main import create_app
pytestmark = pytest.mark.integration
#: 公开端点绝不能出现的字段(账户与客户数据)
FORBIDDEN_FIELDS = {
"customer_id", "account_id", "trade_account", "available_cash",
"total_asset", "total_quantity", "average_cost", "cost_amount",
}
#: 前端三个访客页面直接消费的字段
REQUIRED_FIELDS = {
"product_code", "product_name", "exchange_code", "product_category", "risk_level",
"fund_manager", "current_nav", "current_nav_at", "status", "lot_size", "price_tick",
"management_fee_rate", "custodian_fee_rate", "latest_close", "latest_trade_date",
"quote_source", "change_pct",
}
async def test_visitor_token_can_read_listed_products() -> None:
app = create_app()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test", timeout=30
) as client:
issued = await client.post("/api/v1/visitor-tokens")
# 该端点按创建语义返回 201(本平台创建类端点的统一口径)
assert issued.status_code in (200, 201), issued.text
# 访客令牌端点是 `raw` 形状:令牌直接在顶层,没有 data 信封
token = issued.json()["access_token"]
response = await client.get(
"/api/v1/products", headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200, response.text
data = response.json()["data"]
products = data["products"]
assert products, "产品列表为空:访客页面会显示不出任何基金"
assert data["count"] == len(products)
# 只暴露在售产品
assert all(item["status"] == "上市" for item in products)
first = products[0]
assert REQUIRED_FIELDS <= set(first), f"缺字段:{REQUIRED_FIELDS - set(first)}"
assert not (FORBIDDEN_FIELDS & set(first)), "公开端点混入了账户/客户字段"
# change_pct 只能是数字或 None。None 表示行情不足两个交易日、算不出涨跌,
# **不是 0** —— 前端对 None 显示"暂无",对 0 会显示 "+0.00%"(等于说今天平盘)。
for item in products:
assert item["change_pct"] is None or isinstance(item["change_pct"], (int, float)), (
f"{item['product_code']} 的 change_pct 类型异常:{item['change_pct']!r}"
)
# 价格类字段一律是字符串(与既有接口口径一致)
assert isinstance(first["current_nav"], str)
async def test_visitor_token_can_read_nav_history() -> None:
"""P002:访客能取到历史净值序列(详情页走势图的数据源)。
`fin_nav_history` 为空时返回 `count=0` 与空数组 —— 这是**合法响应**,
不是错误:前端据此显示"尚未接入",而不得回退到编造的曲线。
"""
app = create_app()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test", timeout=30
) as client:
issued = await client.post("/api/v1/visitor-tokens")
token = issued.json()["access_token"]
auth = {"Authorization": f"Bearer {token}"}
listed = await client.get("/api/v1/products", headers=auth)
products = listed.json()["data"]["products"]
assert products, "产品库为空,净值用例无从下手"
code = products[0]["product_code"]
response = await client.get(
f"/api/v1/products/{code}/nav-history", params={"days": 30}, headers=auth
)
assert response.status_code == 200, response.text
data = response.json()["data"]
assert data["product_code"] == code
points = data["points"]
assert data["count"] == len(points)
if not points:
# 还没跑 `tools/sync_nav_history.py` —— 允许,但必须是"干净的空"
return
assert points[0]["nav_date"] <= points[-1]["nav_date"], "净值序列必须按日期升序"
assert all(isinstance(item["nav"], str) for item in points)
assert len(points) <= 30
async def test_nav_history_returns_404_for_unknown_product() -> None:
"""不存在的产品必须是 404,而不是空数组 —— 否则前端分不清"没有数据"和"没有这只"。"""
app = create_app()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test", timeout=30
) as client:
issued = await client.post("/api/v1/visitor-tokens")
token = issued.json()["access_token"]
response = await client.get(
"/api/v1/products/999999/nav-history",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 404, response.text