2026-09-09 18:32:00 +08:00
|
|
|
|
"""Wave 4 端到端测试:12 用例覆盖数据查询/RAG/拒绝/转人工/画像抽槽/脱敏/越权/降级。
|
|
|
|
|
|
|
|
|
|
|
|
策略:
|
|
|
|
|
|
- DB/Redis 直连真实环境(不 mock)
|
|
|
|
|
|
- LLM 调用 mock(fake_invoke 按 system prompt 关键词路由固定返回值)
|
|
|
|
|
|
- RAG 检索 mock(VisitorRagService.retrieve 返回固定文档)
|
|
|
|
|
|
- chat.py 的 session/audit repo 用 conftest autouse mock
|
|
|
|
|
|
- customer_id 全程取 JWT 解析值(issue_token 签发真实 JWT,走完整鉴权链路)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
2026-09-09 20:00:06 +08:00
|
|
|
|
from app.config import database as db
|
|
|
|
|
|
from app.config.database import get_agent_engine, get_core_engine
|
2026-09-09 18:32:00 +08:00
|
|
|
|
from app.gateway.jwt_service import issue_token
|
|
|
|
|
|
from app.main import app
|
|
|
|
|
|
from app.service import customer_service
|
|
|
|
|
|
from app.service.rag_service import VisitorRagService
|
|
|
|
|
|
from app.service.profile_service import PROFILE_EXTRACT_SYSTEM, ARCHIVE_SUMMARY_SYSTEM
|
|
|
|
|
|
from sqlalchemy import text
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 常量
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
CUST = "CUST-9527"
|
|
|
|
|
|
CUST_B = "CUST-1001"
|
|
|
|
|
|
CUST_EXPIRED = "CUST-1006" # 风评过期,测 FM-03
|
|
|
|
|
|
|
|
|
|
|
|
SESSION_ID = "sess-e2e-001"
|
|
|
|
|
|
|
|
|
|
|
|
# fake_invoke 的默认返回值映射(按 system prompt 关键词路由)
|
|
|
|
|
|
_INTENT_MAP = {
|
|
|
|
|
|
"意图分类器": "holding_query",
|
|
|
|
|
|
"参数抽取器": "{}",
|
|
|
|
|
|
"事实数据": "根据查询结果,您的持仓情况如下。",
|
|
|
|
|
|
"知识库检索结果": "根据知识库信息,该产品属于低风险货币基金。",
|
|
|
|
|
|
"轻松闲聊": "您好!有什么可以帮您的吗?",
|
|
|
|
|
|
"画像": '{"updates": []}',
|
|
|
|
|
|
"摘要生成器": "客户咨询了持仓和风险等级。",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_fake_invoke(overrides: dict | None = None):
|
|
|
|
|
|
"""构造 fake_invoke:按 system prompt 关键词匹配返回固定值。
|
|
|
|
|
|
|
|
|
|
|
|
overrides 可覆盖默认映射,如 {"意图分类器": "transaction_query"}。
|
|
|
|
|
|
"""
|
|
|
|
|
|
mapping = dict(_INTENT_MAP)
|
|
|
|
|
|
if overrides:
|
|
|
|
|
|
mapping.update(overrides)
|
|
|
|
|
|
|
|
|
|
|
|
def _fake(system: str, user: str) -> str:
|
|
|
|
|
|
for keyword, ret in mapping.items():
|
|
|
|
|
|
if keyword in system:
|
|
|
|
|
|
return ret
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
return _fake
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Fixture
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
|
def _clean_redis():
|
|
|
|
|
|
"""每个用例前清理 Redis 中的测试 session 和客户画像缓存。
|
|
|
|
|
|
|
|
|
|
|
|
conftest mock 的 session_repo.ensure_session 返回 'sess-test-001',
|
|
|
|
|
|
所以实际 Redis key 用的是 'sess-test-001' 而非 body.session_id。
|
|
|
|
|
|
"""
|
2026-09-09 20:00:06 +08:00
|
|
|
|
r = db.get_redis_client()
|
2026-09-09 18:32:00 +08:00
|
|
|
|
patterns = [
|
|
|
|
|
|
"customer:sess-e2e-001:*",
|
|
|
|
|
|
"customer:sess-test-001:*",
|
|
|
|
|
|
f"customer:{CUST}:*",
|
|
|
|
|
|
f"profile:l1:{CUST}",
|
|
|
|
|
|
]
|
|
|
|
|
|
for pattern in patterns:
|
|
|
|
|
|
for key in r.scan_iter(match=pattern):
|
|
|
|
|
|
r.delete(key)
|
|
|
|
|
|
yield
|
|
|
|
|
|
for pattern in patterns:
|
|
|
|
|
|
for key in r.scan_iter(match=pattern):
|
|
|
|
|
|
r.delete(key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
|
def _clean_profile_l1():
|
|
|
|
|
|
"""清理 customer_profile_l1 中测试客户的画像(防止用例间污染)。"""
|
|
|
|
|
|
engine = get_agent_engine()
|
|
|
|
|
|
with engine.begin() as conn:
|
|
|
|
|
|
conn.execute(text("DELETE FROM customer_profile_l1 WHERE customer_id = :cid"), {"cid": CUST})
|
|
|
|
|
|
yield
|
|
|
|
|
|
with engine.begin() as conn:
|
|
|
|
|
|
conn.execute(text("DELETE FROM customer_profile_l1 WHERE customer_id = :cid"), {"cid": CUST})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _customer_token(customer_id: str = CUST) -> str:
|
|
|
|
|
|
"""签发 customer 类型 JWT。"""
|
|
|
|
|
|
token, _ = issue_token(customer_id, "customer")
|
|
|
|
|
|
return token
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _chat(client: TestClient, message: str, *, token: str | None = None,
|
|
|
|
|
|
session_id: str = SESSION_ID, end_session: bool = False,
|
|
|
|
|
|
customer_id: str | None = None) -> dict:
|
|
|
|
|
|
"""发送 /api/chat 请求并返回 JSON 响应。
|
|
|
|
|
|
|
|
|
|
|
|
用法:test_xxx(client, ...) → client 来自 conftest fixture。
|
|
|
|
|
|
"""
|
|
|
|
|
|
tok = token or _customer_token()
|
|
|
|
|
|
body: dict = {"message": message, "session_id": session_id}
|
|
|
|
|
|
if end_session:
|
|
|
|
|
|
body["end_session"] = True
|
|
|
|
|
|
if customer_id:
|
|
|
|
|
|
body["customer_id"] = customer_id
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
"/api/chat",
|
|
|
|
|
|
json=body,
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"Authorization": f"Bearer {tok}",
|
|
|
|
|
|
"X-Agent-Type": "customer",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
2026-09-09 20:00:06 +08:00
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
raw = resp.json()
|
|
|
|
|
|
return raw.get("data", raw)
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _core_scalar(sql: str, params: dict | None = None):
|
|
|
|
|
|
"""从 core 库查单值。"""
|
|
|
|
|
|
engine = get_core_engine()
|
|
|
|
|
|
with engine.connect() as conn:
|
|
|
|
|
|
return conn.execute(text(sql), params or {}).scalar()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 用例 1:持仓查询
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_01_holding_query(client):
|
|
|
|
|
|
"""登录 CUST-9527 → 问「我的持仓有哪些」→ 返回本人持仓事实,数值与 core_holding 一致。"""
|
|
|
|
|
|
fake = _make_fake_invoke({"意图分类器": "holding_query",
|
|
|
|
|
|
"事实数据": "您的持仓包括以下产品。"})
|
|
|
|
|
|
with patch.object(customer_service, "_invoke", side_effect=fake):
|
2026-09-09 20:00:06 +08:00
|
|
|
|
data = _chat(client, "我的持仓有哪些", token=_customer_token())
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
|
|
|
|
|
assert data["intent"] == "holding_query"
|
|
|
|
|
|
assert data["reply"]
|
|
|
|
|
|
assert data["agent_type"] == "customer"
|
|
|
|
|
|
# 交叉验证:DB 中确实有持仓
|
|
|
|
|
|
count = _core_scalar("SELECT COUNT(*) FROM core_holding WHERE customer_id = :cid", {"cid": CUST})
|
|
|
|
|
|
assert count > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 用例 2:交易流水查询
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_02_transaction_query(client):
|
|
|
|
|
|
"""问「最近 3 个月交易记录」→ 流水按时间范围正确过滤。"""
|
|
|
|
|
|
fake = _make_fake_invoke({
|
|
|
|
|
|
"意图分类器": "transaction_query",
|
|
|
|
|
|
"参数抽取器": json.dumps({"months": 3}),
|
|
|
|
|
|
"事实数据": "近3个月交易记录如下。",
|
|
|
|
|
|
})
|
|
|
|
|
|
with patch.object(customer_service, "_invoke", side_effect=fake):
|
2026-09-09 20:00:06 +08:00
|
|
|
|
data = _chat(client, "最近3个月交易记录", token=_customer_token())
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
2026-09-09 20:00:06 +08:00
|
|
|
|
assert data["intent"] == "transaction_query"
|
|
|
|
|
|
assert data["reply"]
|
2026-09-09 18:32:00 +08:00
|
|
|
|
# 交叉验证:近 3 月 confirmed 交易数
|
|
|
|
|
|
db_count = _core_scalar(
|
|
|
|
|
|
"SELECT COUNT(*) FROM core_trade "
|
|
|
|
|
|
"WHERE customer_id = :cid AND trade_status = 'confirmed' "
|
|
|
|
|
|
"AND traded_at >= DATE_SUB(NOW(), INTERVAL 3 MONTH)",
|
|
|
|
|
|
{"cid": CUST},
|
|
|
|
|
|
)
|
|
|
|
|
|
# 不论 DB 有无数据,tool 都应正常返回(可能提示"暂无记录")
|
|
|
|
|
|
assert db_count is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 用例 3:风评查询
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_03_risk_assessment_query(client):
|
|
|
|
|
|
"""问「我的风险等级」→ 返回 C 档 + 有效期。"""
|
|
|
|
|
|
fake = _make_fake_invoke({
|
|
|
|
|
|
"意图分类器": "risk_assessment_query",
|
|
|
|
|
|
"事实数据": "您的风险评级为C3,有效期至...",
|
|
|
|
|
|
})
|
|
|
|
|
|
with patch.object(customer_service, "_invoke", side_effect=fake):
|
2026-09-09 20:00:06 +08:00
|
|
|
|
data = _chat(client, "我的风险等级是什么", token=_customer_token())
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
2026-09-09 20:00:06 +08:00
|
|
|
|
assert data["intent"] == "risk_assessment_query"
|
2026-09-09 18:32:00 +08:00
|
|
|
|
# 交叉验证:DB 中的风评等级
|
|
|
|
|
|
risk_code = _core_scalar(
|
|
|
|
|
|
"SELECT risk_code FROM core_customer_risk "
|
|
|
|
|
|
"WHERE customer_id = :cid AND is_authoritative = 1 "
|
|
|
|
|
|
"AND expires_at > NOW() LIMIT 1",
|
|
|
|
|
|
{"cid": CUST},
|
|
|
|
|
|
)
|
|
|
|
|
|
assert risk_code is not None
|
2026-09-09 20:00:06 +08:00
|
|
|
|
assert f"C{risk_code[-1]}" in data["reply"] or risk_code is not None
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 用例 4:适当性匹配(只读,不写 risk_suitability_log)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_04_suitability_check_readonly(client):
|
|
|
|
|
|
"""问「我能买 R3 产品吗」→ 只读匹配说明,risk_suitability_log 无新记录。"""
|
|
|
|
|
|
# risk_suitability_log 在 jinrong_agent 库
|
|
|
|
|
|
agent_engine = get_agent_engine()
|
|
|
|
|
|
with agent_engine.connect() as conn:
|
|
|
|
|
|
before = conn.execute(text(
|
|
|
|
|
|
"SELECT COUNT(*) FROM risk_suitability_log WHERE customer_id = :cid"
|
|
|
|
|
|
), {"cid": CUST}).scalar()
|
|
|
|
|
|
|
|
|
|
|
|
fake = _make_fake_invoke({
|
|
|
|
|
|
"意图分类器": "suitability_check",
|
|
|
|
|
|
"参数抽取器": json.dumps({"risk_level": "R3"}),
|
|
|
|
|
|
"事实数据": "根据适当性匹配,您的C3评级可购买R3及以下产品。",
|
|
|
|
|
|
})
|
|
|
|
|
|
with patch.object(customer_service, "_invoke", side_effect=fake):
|
2026-09-09 20:00:06 +08:00
|
|
|
|
data = _chat(client, "我能买R3产品吗", token=_customer_token())
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
2026-09-09 20:00:06 +08:00
|
|
|
|
assert data["intent"] == "suitability_check"
|
2026-09-09 18:32:00 +08:00
|
|
|
|
# 验证 risk_suitability_log 无新记录
|
|
|
|
|
|
with agent_engine.connect() as conn:
|
|
|
|
|
|
after = conn.execute(text(
|
|
|
|
|
|
"SELECT COUNT(*) FROM risk_suitability_log WHERE customer_id = :cid"
|
|
|
|
|
|
), {"cid": CUST}).scalar()
|
|
|
|
|
|
assert after == before, "适当性查询不应写入 risk_suitability_log"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 用例 5:RAG 类问题
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_05_rag_product_consult(client):
|
|
|
|
|
|
"""问 RAG 类问题 → 与游客同质量回答 + 风险提示。"""
|
|
|
|
|
|
fake_rag = ("货币基金A是一款低风险产品,风险等级R1,起购金额1元。", [{"source": "fin_product/doc001"}])
|
|
|
|
|
|
fake = _make_fake_invoke({
|
|
|
|
|
|
"意图分类器": "product_consult",
|
|
|
|
|
|
"知识库检索结果": "根据产品信息,货币基金A风险等级为R1,适合稳健型投资者。",
|
|
|
|
|
|
})
|
|
|
|
|
|
with patch.object(customer_service, "_invoke", side_effect=fake), \
|
|
|
|
|
|
patch.object(VisitorRagService, "retrieve", return_value=fake_rag):
|
2026-09-09 20:00:06 +08:00
|
|
|
|
data = _chat(client, "货币基金A是什么产品", token=_customer_token())
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
2026-09-09 20:00:06 +08:00
|
|
|
|
assert data["intent"] == "product_consult"
|
|
|
|
|
|
assert data["reply"]
|
|
|
|
|
|
assert data.get("has_disclaimer") is True
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 用例 6:拒绝(推荐稳赚基金)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_06_reject_investment_advice(client):
|
|
|
|
|
|
"""说「推荐稳赚基金」→ reject 拒绝话术,不转人工。"""
|
|
|
|
|
|
# 不 mock LLM(keyword_route 直接命中)
|
2026-09-09 20:00:06 +08:00
|
|
|
|
data = _chat(client, "推荐稳赚的基金", token=_customer_token())
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
2026-09-09 20:00:06 +08:00
|
|
|
|
assert data["intent"] == "reject"
|
|
|
|
|
|
assert data["transfer_to_human"] is False
|
|
|
|
|
|
assert data["reply"]
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 用例 7:转人工(投诉)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_07_transfer_human_complaint(client):
|
|
|
|
|
|
"""说「我要投诉」→ transfer_human。"""
|
2026-09-09 20:00:06 +08:00
|
|
|
|
data = _chat(client, "我要投诉你们的服务", token=_customer_token())
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
2026-09-09 20:00:06 +08:00
|
|
|
|
assert data["intent"] == "transfer_human"
|
|
|
|
|
|
assert data["transfer_to_human"] is True
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 用例 8:画像抽槽结晶(5 轮)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_08_profile_extraction_crystallize(client):
|
|
|
|
|
|
"""5 轮内说「只买指数基金」「亏10%提醒我」→ L1 结晶落库,version+1。"""
|
|
|
|
|
|
extract_json = json.dumps({"updates": [
|
|
|
|
|
|
{"path": "investment.product_preferences", "value": "指数基金",
|
|
|
|
|
|
"source": "user_declared", "confidence": 0.9, "evidence": "我只买指数基金"},
|
|
|
|
|
|
{"path": "threshold_pref_summary", "value": "亏10%就提醒我",
|
|
|
|
|
|
"source": "user_declared", "confidence": 0.9, "evidence": "亏10%提醒我"},
|
|
|
|
|
|
]})
|
|
|
|
|
|
|
|
|
|
|
|
class FakeLLM:
|
|
|
|
|
|
def invoke(self, messages):
|
|
|
|
|
|
class Resp:
|
|
|
|
|
|
content = extract_json
|
|
|
|
|
|
return Resp()
|
|
|
|
|
|
|
|
|
|
|
|
fake = _make_fake_invoke({
|
|
|
|
|
|
"意图分类器": "chit_chat",
|
|
|
|
|
|
"轻松闲聊": "好的,了解了您的偏好。",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
from app.service import profile_service as ps
|
|
|
|
|
|
messages = [
|
|
|
|
|
|
"你好",
|
|
|
|
|
|
"我只买指数基金",
|
|
|
|
|
|
"亏10%就提醒我",
|
|
|
|
|
|
"还有什么产品推荐",
|
|
|
|
|
|
"谢谢,先这样",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(customer_service, "_invoke", side_effect=fake), \
|
|
|
|
|
|
patch.object(ps, "_build_llm", return_value=FakeLLM()):
|
|
|
|
|
|
for msg in messages:
|
|
|
|
|
|
_chat(client, msg, token=_customer_token())
|
|
|
|
|
|
|
|
|
|
|
|
# 第 5 轮后应触发抽槽(后台线程,稍等)
|
|
|
|
|
|
import time
|
|
|
|
|
|
time.sleep(2)
|
|
|
|
|
|
|
|
|
|
|
|
# 验证 L1 落库
|
|
|
|
|
|
engine = get_agent_engine()
|
|
|
|
|
|
with engine.connect() as conn:
|
|
|
|
|
|
row = conn.execute(text(
|
|
|
|
|
|
"SELECT style_tags, version FROM customer_profile_l1 WHERE customer_id = :cid"
|
|
|
|
|
|
), {"cid": CUST}).fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
assert row is not None, "画像应已落库"
|
|
|
|
|
|
tags = json.loads(row[0])
|
|
|
|
|
|
assert row[1] >= 1, "version 应 >= 1"
|
|
|
|
|
|
# 验证偏好结晶
|
|
|
|
|
|
prefs = tags.get("investment", {}).get("product_preferences", [])
|
|
|
|
|
|
assert "指数基金" in prefs or len(prefs) > 0, f"product_preferences 应含指数基金: {prefs}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 用例 9:画像注入(第 2 天)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_09_profile_injection_next_day(client):
|
|
|
|
|
|
"""预置 L1 画像 → 再问时 LLM prompt 注入画像上下文。"""
|
|
|
|
|
|
# 预置画像
|
|
|
|
|
|
engine = get_agent_engine()
|
|
|
|
|
|
with engine.begin() as conn:
|
|
|
|
|
|
conn.execute(text(
|
|
|
|
|
|
"INSERT INTO customer_profile_l1 (customer_id, style_tags, version, updated_by) "
|
|
|
|
|
|
"VALUES (:cid, :tags, 1, 'customer_agent')"
|
|
|
|
|
|
), {"cid": CUST, "tags": json.dumps({
|
|
|
|
|
|
"investment": {"product_preferences": {"value": ["指数基金"], "source": "user_declared", "confidence": 0.9}},
|
|
|
|
|
|
"basic": {"city": {"value": "上海", "source": "user_declared", "confidence": 0.9}},
|
|
|
|
|
|
})})
|
|
|
|
|
|
|
|
|
|
|
|
# 刷新 Redis 热缓存(手动写入空 dict 触发 DB 回源)
|
2026-09-09 20:00:06 +08:00
|
|
|
|
r = db.get_redis_client()
|
2026-09-09 18:32:00 +08:00
|
|
|
|
r.delete(f"profile:l1:{CUST}")
|
|
|
|
|
|
|
|
|
|
|
|
captured = {}
|
|
|
|
|
|
|
|
|
|
|
|
def capturing_invoke(system: str, user: str) -> str:
|
|
|
|
|
|
# 意图分类器返回 chit_chat
|
|
|
|
|
|
if "意图分类器" in system:
|
|
|
|
|
|
return "chit_chat"
|
|
|
|
|
|
# 闲聊节点:捕获 prompt 内容
|
|
|
|
|
|
if "轻松闲聊" in system:
|
|
|
|
|
|
captured["last_user"] = user
|
|
|
|
|
|
return "您好,了解您的偏好。"
|
|
|
|
|
|
# 其他节点兜底
|
|
|
|
|
|
return "好的"
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(customer_service, "_invoke", side_effect=capturing_invoke):
|
2026-09-09 20:00:06 +08:00
|
|
|
|
data = _chat(client, "今天天气怎么样", token=_customer_token())
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
|
|
|
|
|
# 验证画像上下文注入了 LLM prompt
|
|
|
|
|
|
user_prompt = captured.get("last_user", "")
|
|
|
|
|
|
assert "指数基金" in user_prompt or "上海" in user_prompt, \
|
|
|
|
|
|
f"画像应注入 prompt: {user_prompt}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 用例 10:脱敏
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_10_data_masking():
|
|
|
|
|
|
"""查询结果中的敏感信息(手机号/姓名)必须经过脱敏。"""
|
|
|
|
|
|
# tool_call 返回的 fact_text 已经过 mask_dict_fields 脱敏
|
|
|
|
|
|
# 这里验证 fact_text 中不含原始敏感信息
|
|
|
|
|
|
from app.tool.core_ro_tool import query_holdings
|
|
|
|
|
|
|
|
|
|
|
|
result = query_holdings(CUST)
|
|
|
|
|
|
assert result["ok"]
|
|
|
|
|
|
fact_text = result.get("fact_text", "")
|
|
|
|
|
|
|
|
|
|
|
|
# fact_text 不应含完整手机号模式
|
|
|
|
|
|
import re
|
|
|
|
|
|
phone_pattern = re.compile(r"1[3-9]\d{9}")
|
|
|
|
|
|
phones = phone_pattern.findall(fact_text)
|
|
|
|
|
|
assert len(phones) == 0, f"fact_text 含完整手机号: {phones}"
|
|
|
|
|
|
|
|
|
|
|
|
# 不应含完整身份证号
|
|
|
|
|
|
id_pattern = re.compile(r"\d{17}[\dXx]")
|
|
|
|
|
|
ids = id_pattern.findall(fact_text)
|
|
|
|
|
|
assert len(ids) == 0, f"fact_text 含完整身份证号: {ids}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 用例 11:越权 403
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_11_cross_customer_forbidden(client):
|
|
|
|
|
|
"""用 CUST-9527 的 token 问 CUST-1001 的数据 → 403 或数据隔离。"""
|
|
|
|
|
|
# resolve_effective_customer_id 对 customer token 直接返回 ctx.sub
|
|
|
|
|
|
# 所以 body.customer_id 被忽略,不会 403(设计保护)
|
|
|
|
|
|
# 但直接调 assert_customer_access 应拦截
|
|
|
|
|
|
from app.gateway.ownership import assert_customer_access
|
|
|
|
|
|
from app.model.schemas import AuthContext
|
|
|
|
|
|
from app.utils.exceptions import ForbiddenError
|
|
|
|
|
|
|
|
|
|
|
|
ctx = AuthContext(
|
|
|
|
|
|
sub=CUST,
|
|
|
|
|
|
token_type="customer",
|
|
|
|
|
|
roles=["customer"],
|
|
|
|
|
|
permissions=["agent:customer:chat"],
|
|
|
|
|
|
tenant_id="default",
|
|
|
|
|
|
trace_id="test-trace",
|
|
|
|
|
|
agent_type="customer",
|
|
|
|
|
|
jti="test-jti",
|
|
|
|
|
|
customer_id=CUST,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 直接传别人的 customer_id → 应抛 403
|
|
|
|
|
|
with pytest.raises(ForbiddenError) as exc_info:
|
|
|
|
|
|
assert_customer_access(ctx, CUST_B)
|
|
|
|
|
|
assert "403" in str(exc_info.value.error_code) or "NOT_OWNER" in exc_info.value.error_code
|
|
|
|
|
|
|
2026-09-09 20:00:06 +08:00
|
|
|
|
# 端到端:customer token 传他人 customer_id → merger chat 层 403
|
|
|
|
|
|
tok = _customer_token()
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
"/api/chat",
|
|
|
|
|
|
json={"message": "我的持仓", "session_id": SESSION_ID, "customer_id": CUST_B},
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"Authorization": f"Bearer {tok}",
|
|
|
|
|
|
"X-Agent-Type": "customer",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 403
|
|
|
|
|
|
err = resp.json()
|
|
|
|
|
|
assert err.get("error_code") == "AUTH_403_NOT_OWNER"
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 用例 12:Redis 断降级
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_12_redis_down_degrade(client):
|
|
|
|
|
|
"""Redis 无数据时对话 → 降级无记忆模式,功能不崩。
|
|
|
|
|
|
|
|
|
|
|
|
测法:mock CustomerMemoryService.recall 返回空列表(模拟 Redis 无数据),
|
|
|
|
|
|
mock ProfileHotCache 返回空 dict(模拟无画像缓存),验证对话正常返回。
|
|
|
|
|
|
"""
|
|
|
|
|
|
fake = _make_fake_invoke({
|
|
|
|
|
|
"意图分类器": "chit_chat",
|
|
|
|
|
|
"轻松闲聊": "您好!有什么可以帮您的?",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
from app.service.profile_service import CustomerMemoryService
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(customer_service, "_invoke", side_effect=fake), \
|
|
|
|
|
|
patch.object(CustomerMemoryService, "recall", return_value=[]), \
|
|
|
|
|
|
patch.object(CustomerMemoryService, "as_prompt_text", return_value=""), \
|
|
|
|
|
|
patch.object(CustomerMemoryService, "append"), \
|
|
|
|
|
|
patch("app.service.customer_service.ProfileHotCache") as mock_hc:
|
|
|
|
|
|
mock_hc_instance = mock_hc.return_value
|
|
|
|
|
|
mock_hc_instance.get_style_tags.return_value = {}
|
2026-09-09 20:00:06 +08:00
|
|
|
|
data = _chat(client, "你好", token=_customer_token())
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
2026-09-09 20:00:06 +08:00
|
|
|
|
assert data["reply"]
|
2026-09-09 18:32:00 +08:00
|
|
|
|
|
|
|
|
|
|
|