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:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user