Merge pull request '信封补齐、适当性矩阵修正与 Worker 失败原因落库' (#5) from qyqy_develop_1 into qyqy_develop

合并 qyqy_develop_1 第三轮工作:meta 信封补齐与公共化、适当性按第十二条匹配矩阵、Worker 失败原因区分固定文案与异常消息。
This commit was merged in pull request #5.
This commit is contained in:
2026-09-11 16:42:29 +08:00
18 changed files with 704 additions and 68 deletions
+119
View File
@@ -0,0 +1,119 @@
"""成功响应的统一信封(`docs/05` §3.3)。
§3.3 规定成功响应是 `{data, meta}`,列表的 `data` 为**纯数组**、`next_cursor` 与
`has_more` 放在 `meta` 里,并且明确「业务接口不得增加其他顶层字段」。
此前有三个端点漏了这件事,它们都是"service 直接把内部结构当响应体返回":
- `GET /conversations/{session_id}/messages` → 裸 `{"data": [...]}`,`meta` 整个缺失,
游标也没有地方放;
- `POST /conversation-messages/{id}/feedback` → 同样没有 `meta`;
- `GET /knowledge-references/{token}` → 直接返回资源对象。
注意"缺 meta"很容易被误判成"有":`X-Trace-ID` 是**响应头**(由中间件加),和 body 里的
`meta.trace_id` 是两件事;错误响应一直有 `meta`(异常处理器统一加),只有成功路径漏了。
所以这里断言的是 `set(body)`,多一个或少一个顶层字段都会红。
"""
from typing import Any
import pytest
from fastapi.testclient import TestClient
from app.api.controllers import conversations as conversations_controller
from app.api.controllers import knowledge as knowledge_controller
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.database import get_session
from app.core.contracts import RequestContext
from app.main import create_app
TRACE = "trace-envelope"
async def resolve_context() -> RequestContext:
return RequestContext(
user_id="9001",
trace_id=TRACE,
permissions=("conversation:create", "conversation:feedback", "knowledge:reference:read"),
)
class StubConversationService:
def __init__(self, _session: Any) -> None:
pass
async def messages(
self, _session_id: str, _context: RequestContext, _limit: int, before: int | None = None
) -> dict[str, Any]:
del before
return {
"items": [
{
"message_id": "11",
"role": "user",
"content": "稳健型",
"created_at": "2026-09-10T00:00:00Z",
}
],
"next_cursor": "11",
"has_more": True,
}
async def feedback(
self,
_message_id: int,
_context: RequestContext,
_rating: int,
_feedback_type: str | None,
_feedback_content: str | None,
) -> dict[str, Any]:
return {"feedback_no": "fb-1", "status": "open"}
class StubKnowledgeService:
async def resolve(self, _context: RequestContext, _token: str) -> dict[str, Any]:
return {"knowledge_id": 7, "title": "个人投资者适当性管理指南"}
def envelope_client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
monkeypatch.setattr(
conversations_controller, "ConversationService", StubConversationService
)
monkeypatch.setattr(knowledge_controller, "KnowledgeReferenceService", StubKnowledgeService)
application = create_app()
application.dependency_overrides[build_request_context] = resolve_context
application.dependency_overrides[get_session] = lambda: None
return TestClient(application)
def test_message_list_puts_cursor_in_meta(monkeypatch: pytest.MonkeyPatch) -> None:
with envelope_client(monkeypatch) as http:
response = http.get("/api/v1/conversations/session-1/messages", params={"limit": 20})
assert response.status_code == 200
body = response.json()
assert set(body) == {"data", "meta"}
assert isinstance(body["data"], list)
assert body["data"][0]["message_id"] == "11"
assert body["meta"] == {"trace_id": TRACE, "next_cursor": "11", "has_more": True}
def test_feedback_success_response_has_meta(monkeypatch: pytest.MonkeyPatch) -> None:
with envelope_client(monkeypatch) as http:
response = http.post("/api/v1/conversation-messages/11/feedback", json={"rating": 1})
assert response.status_code == 201
body = response.json()
assert set(body) == {"data", "meta"}
assert body["data"] == {"feedback_no": "fb-1", "status": "open"}
assert body["meta"] == {"trace_id": TRACE}
def test_knowledge_reference_success_response_has_meta(monkeypatch: pytest.MonkeyPatch) -> None:
with envelope_client(monkeypatch) as http:
response = http.get(f"/api/v1/knowledge-references/{'a' * 20}")
assert response.status_code == 200
body = response.json()
assert set(body) == {"data", "meta"}
assert body["meta"] == {"trace_id": TRACE}
@@ -108,14 +108,20 @@ async def test_messages_are_scoped_to_current_user(monkeypatch: pytest.MonkeyPat
# user_id 必须透传:Repository 靠它做归属过滤,漏传就等于不限范围。
assert captured["user_id"] == 9001
assert captured["limit"] == 20
# 21 = limit + 1:service 多取一行判断"还有没有更旧的",用来填 §3.3 的 has_more。
# 只看"取满没取满"会把恰好等于 limit 的最后一页说成还有下一页。
assert captured["limit"] == 21
assert captured["session_id"] == "session-1"
# 不带游标时必须传 None,行为与加游标前一致(取最新一页)。
assert captured["before"] is None
assert result["data"] == [
# service 返回内部结构,Controller 用 list_envelope 拆成 {data, meta}(§3.3)。
# 一行数据小于 limit,所以没有下一页。
assert result["items"] == [
{"message_id": "11", "role": "user", "content": "稳健型",
"created_at": NOW.isoformat() + "Z"}
]
assert result["has_more"] is False
assert result["next_cursor"] is None
async def test_messages_forward_cursor_to_repository(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -156,7 +162,7 @@ async def test_feedback_writes_feedback_and_audit_with_trace_id(
result = await ConversationService(session).feedback(11, CONTEXT, 1, "rating", "很有帮助")
assert result["data"]["status"] == "open"
assert result["status"] == "open"
kinds = [type(item).__name__ for item in session.added]
assert kinds == ["ConversationFeedback", "InteractionAudit"]
audit = session.added[1]
@@ -63,6 +63,45 @@ def test_disclosure_and_recording_are_disclosed_when_required() -> None:
assert "双录" in text
def test_one_level_up_is_not_described_as_within_capacity() -> None:
"""C1 买 R2 是矩阵允许的,但不能说成"在您的风险承受能力范围内"——它超出了等级。
这句错话会让客户以为自己的测评等级本来就覆盖 R2,下次买 R3 时就更难解释为什么不行。
"""
text = CustomerServiceAgent._suitability_text(
"南方季季盈90天", 2, _decision(customer_risk_level=1)
)
assert "在您的风险承受能力范围内" not in text
assert "匹配矩阵允许购买的范围内" in text
def test_disclosure_tier_explains_the_condition() -> None:
"""C3 买 R4 属豁免档:要说清"高于您的等级"以及"签揭示书后可买"这个条件。"""
text = CustomerServiceAgent._suitability_text(
"南方稳健增利180天",
4,
_decision(
customer_risk_level=3,
reason_code="SUITABLE_WITH_DISCLOSURE",
required_disclosure=True,
requires_recording=True,
),
)
assert "高于您的风险承受能力等级" in text
assert "签署产品风险揭示书后可以购买" in text
assert "在您的风险承受能力范围内" not in text
def test_same_level_still_uses_the_capacity_wording() -> None:
text = CustomerServiceAgent._suitability_text(
"南方季季盈90天", 2, _decision(customer_risk_level=2)
)
assert "在您的风险承受能力范围内" in text
def test_rejection_does_not_assert_a_reason() -> None:
"""拒绝的原因可能是等级不匹配、测评过期或未测评,不能一律说成"超出承受能力"。"""
text = CustomerServiceAgent._suitability_text(
+44 -1
View File
@@ -101,14 +101,57 @@ def test_caller_cannot_declare_risk_facts(forged: dict[str, Any]) -> None:
async def test_insufficient_authority_level_is_denied() -> None:
"""低两个等级及以上仍必须拒绝(第十四条第 2、3 款,矩阵里的"❌ 禁止")。"""
decision = await service_with_row(authority_row(investor_type="C1")).evaluate(
query(product_risk_level=2), context(), now=NOW
query(product_risk_level=3), context(), now=NOW
)
assert decision.allowed is False
assert decision.reason_code == "RISK_LEVEL_MISMATCH"
assert decision.requires_recording is True
@pytest.mark.parametrize(
("investor_type", "product_level", "allowed", "reason_code"),
[
# 逐格抄自 knowledge/policy/个人投资者适当性管理指南.md 第十二条矩阵。
# 这张表的价值在于:任何一格被改动,都必须是有意为之并在此处说明理由。
("C1", 1, True, "SUITABLE"), ("C1", 2, True, "SUITABLE"),
("C1", 3, False, "RISK_LEVEL_MISMATCH"), ("C1", 4, False, "RISK_LEVEL_MISMATCH"),
("C1", 5, False, "RISK_LEVEL_MISMATCH"),
("C2", 1, True, "SUITABLE"), ("C2", 2, True, "SUITABLE"), ("C2", 3, True, "SUITABLE"),
("C2", 4, False, "RISK_LEVEL_MISMATCH"), ("C2", 5, False, "RISK_LEVEL_MISMATCH"),
("C3", 1, True, "SUITABLE"), ("C3", 2, True, "SUITABLE"), ("C3", 3, True, "SUITABLE"),
("C3", 4, True, "SUITABLE_WITH_DISCLOSURE"), ("C3", 5, False, "RISK_LEVEL_MISMATCH"),
("C4", 1, True, "SUITABLE"), ("C4", 2, True, "SUITABLE"), ("C4", 3, True, "SUITABLE"),
("C4", 4, True, "SUITABLE"), ("C4", 5, True, "SUITABLE_WITH_DISCLOSURE"),
("C5", 1, True, "SUITABLE"), ("C5", 2, True, "SUITABLE"), ("C5", 3, True, "SUITABLE"),
("C5", 4, True, "SUITABLE"), ("C5", 5, True, "SUITABLE"),
],
)
async def test_full_matrix_matches_policy_document(
investor_type: str, product_level: int, allowed: bool, reason_code: str
) -> None:
"""客服回答必须与知识库里的矩阵一致 —— 这是同一个 Agent 的两条出口。"""
decision = await service_with_row(authority_row(investor_type=investor_type)).evaluate(
query(product_risk_level=product_level), context(), now=NOW
)
assert decision.allowed is allowed
assert decision.reason_code == reason_code
async def test_disclosure_tier_always_requires_disclosure_and_recording() -> None:
"""C3→R4、C4→R5 是第十五条豁免档:可买,但必须揭示、确认、录音。"""
for investor_type, product_level in (("C3", 4), ("C4", 5)):
decision = await service_with_row(authority_row(investor_type=investor_type)).evaluate(
query(product_risk_level=product_level), context(), now=NOW
)
assert decision.allowed is True
assert decision.reason_code == "SUITABLE_WITH_DISCLOSURE"
assert decision.required_disclosure is True
assert decision.requires_confirmation is True
assert decision.requires_recording is True
async def test_expired_assessment_is_denied_even_for_eligible_level() -> None:
row = authority_row(investor_type="C5", valid_until=NOW - timedelta(seconds=1))
decision = await service_with_row(row).evaluate(query(product_risk_level=1), context(), now=NOW)
+24 -1
View File
@@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, Mock
import pytest
from app.model.platform import DomainEventOutbox
from app.worker.outbox_worker import OutboxWorker
from app.worker.outbox_worker import OutboxHandlerError, OutboxWorker
def event() -> DomainEventOutbox:
@@ -66,9 +66,32 @@ async def test_handler_failure_records_attempt(attempts: int, expected: str) ->
assert row.status == expected
assert row.retry_count == attempts + 1
assert "do-not-log" not in (row.last_error or "")
# 其他异常一律只记类名:消息可能含凭据/SQL/客户标识,不落库。
assert row.last_error == "RuntimeError"
session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_handler_error_reason_is_recorded() -> None:
"""`OutboxHandlerError` 的固定文案可以落库 —— 否则失败原因根本无从分辨。
实测库里 373 条死信的 `last_error` 全是裸的 `"ValueError"`:`dispatch`(run not
found)、`dispatch_run_completed`、`dispatch_memory_extraction`、
`dispatch_profile_rebuild` 抛的都是 ValueError,只记类名等于把"哪一处失败"也丢了。
这些 handler 现在改抛 `OutboxHandlerError`,它的 reason 由代码写死、不含请求数据。
"""
row = event()
session = AsyncMock()
session.add = Mock()
session.scalar.side_effect = [row, None]
handler = AsyncMock(side_effect=OutboxHandlerError("run not found"))
await OutboxWorker(session, {"test.event": handler}).publish_one()
assert row.last_error == "OutboxHandlerError: run not found"
assert row.status == "failed"
@pytest.mark.asyncio
async def test_empty_matching_queue_releases_transaction() -> None:
session = AsyncMock()