test_channel_acceptance.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. import asyncio
  2. from uuid import UUID, uuid4
  3. import pytest
  4. from langchain_core.messages import AIMessage, message_to_dict
  5. from sqlalchemy import func, select
  6. from test_after_sales import confirm_proposal, make_proposal, scenario # noqa: F401
  7. from test_channel_inbox import channel, envelope # noqa: F401
  8. from test_fulfillment import connected, due, operation_for # noqa: F401
  9. from test_platform_management import receiver # noqa: F401
  10. from test_storefront import challenge, proof
  11. from zhifu.modules.after_sales.models import BusinessEvent, Ticket
  12. from zhifu.modules.agent_runtime.models import AgentRun
  13. from zhifu.modules.agent_runtime.runtime import build_tool_graph
  14. from zhifu.modules.agent_runtime.worker import claim
  15. from zhifu.modules.conversations.models import Conversation, ConversationEvent
  16. from zhifu.modules.identity.access_models import Principal, StoreGrant
  17. from zhifu.modules.identity.auth import password_hasher
  18. from zhifu.modules.integrations.fulfillment import execute_due, prepare_event
  19. from zhifu.modules.integrations.models import ConsumerReceipt, ExternalOperation, Reservation
  20. from zhifu.modules.merchant.models import ChannelApplication
  21. from zhifu.modules.platform.delivery import enqueue, execute_one
  22. from zhifu.modules.platform.models import ChannelDelivery
  23. from zhifu.oms.models import Exchange
  24. pytestmark = pytest.mark.integration
  25. async def service_account(context):
  26. actor_id = uuid4()
  27. password = uuid4().hex
  28. async with context["app"].state.sessions.begin() as session:
  29. session.add(Principal(
  30. id=actor_id.bytes, tenant_id=context["tenant"].bytes,
  31. username="channel-service-" + actor_id.hex,
  32. password_hash=password_hasher.hash(password), kind="SERVICE",
  33. ))
  34. await session.flush()
  35. for permission in ("identities:verify", "tickets:read"):
  36. session.add(StoreGrant(
  37. tenant_id=context["tenant"].bytes, store_id=context["store"].bytes,
  38. principal_id=actor_id.bytes, permission=permission,
  39. ))
  40. response = await context["client"].post("/openapi/v1/auth/sessions", json={
  41. "tenant_id": str(context["tenant"]), "username": "channel-service-" + actor_id.hex,
  42. "password": password,
  43. })
  44. assert response.status_code == 200, response.text
  45. return {"Authorization": "Bearer " + response.json()["access_token"]}
  46. async def test_h5_webhook_business_query_and_independent_histories(channel): # noqa: F811
  47. async with asyncio.timeout(40):
  48. call = channel["call"]
  49. ticket = await make_proposal(channel)
  50. # 两个真实 SERVICE 账户具有独立访问令牌,共用已授权的通用 webhook 绑定。
  51. second_service = await service_account(channel)
  52. requested = await challenge(channel)
  53. verify_path = (
  54. f"/openapi/v1/channel/identity-challenges/{requested['challenge_id']}/verify"
  55. )
  56. response = await channel["client"].post(
  57. verify_path, json=proof(channel), headers=second_service)
  58. assert response.status_code == 200 and response.json()["status"] == "VERIFIED"
  59. confirm = await call(
  60. "POST", f"/openapi/v1/me/identity-challenges/{requested['challenge_id']}/confirm")
  61. assert confirm.status_code == 200 and confirm.json()["status"] == "LINKED"
  62. path = (f"/openapi/v1/channel/identities/tickets?store_id={channel['store']}"
  63. "&external_subject=verified-channel-account")
  64. results = await asyncio.gather(
  65. call("GET", path, "connector"),
  66. channel["client"].get(path, headers=second_service),
  67. call("GET", f"/openapi/v1/tickets/{ticket['ticket_id']}"),
  68. )
  69. assert all(item.status_code == 200 for item in results)
  70. assert results[0].json()["items"] == results[1].json()["items"] == [results[2].json()]
  71. assert (await call("GET", path, "other")).status_code == 403
  72. assert (await call("GET", path, "foreign")).status_code == 403
  73. assert (await call("GET", path.replace("verified-channel-account", "unlinked"),
  74. "connector")).status_code == 404
  75. assert (await call("GET", f"/openapi/v1/tickets/{ticket['ticket_id']}",
  76. "other")).status_code == 404
  77. h5 = await call("POST", "/openapi/v1/conversations", payload={
  78. "store_id": str(channel["store"])})
  79. assert h5.status_code == 201
  80. h5_id = h5.json()["conversation_id"]
  81. sent = await call("POST", f"/openapi/v1/conversations/{h5_id}/messages", payload={
  82. "text": "H5 会话专属消息", "expected_control_version": 1})
  83. assert sent.status_code == 201, sent.text
  84. external = await channel["send"](envelope(payload={"text": "渠道会话专属消息"}))
  85. assert external.status_code == 202, external.text
  86. external_id = external.json()["conversation_id"]
  87. assert external_id != h5_id
  88. history = await call("GET", f"/openapi/v1/conversations/{h5_id}/messages")
  89. assert "H5 会话专属消息" in history.text and "渠道会话专属消息" not in history.text
  90. external_history = await call(
  91. "GET", f"/openapi/v1/conversations/{external_id}/messages")
  92. assert external_history.status_code == 404
  93. listed = await call("GET", f"/openapi/v1/conversations?store_id={channel['store']}")
  94. assert [item["conversation_id"] for item in listed.json()["items"]] == [h5_id]
  95. async with channel["app"].state.sessions() as session:
  96. conversations = (await session.scalars(select(Conversation).where(
  97. Conversation.tenant_id == channel["tenant"].bytes))).all()
  98. assert sorted(item.channel for item in conversations) == ["h5", "webhook"]
  99. events = (await session.scalars(select(ConversationEvent).where(
  100. ConversationEvent.conversation_id == UUID(external_id).bytes,
  101. ConversationEvent.event_type == "message.user"))).all()
  102. assert len(events) == 1 and events[0].payload["text"] == "渠道会话专属消息"
  103. async def channel_proposal(context):
  104. response = await context["send"](envelope(payload={"text": "申请键盘换货一件"}))
  105. assert response.status_code == 202, response.text
  106. sessions = context["app"].state.sessions
  107. async with sessions.begin() as session:
  108. conversation = await session.get(
  109. Conversation, UUID(response.json()["conversation_id"]).bytes)
  110. run = await session.get(AgentRun, conversation.active_run_id)
  111. # 验收真实工具节点及全部权限检查,不调用生成模型;工具参数来自固定验收订单。
  112. run.messages = run.messages + [message_to_dict(AIMessage(content="", tool_calls=[{
  113. "name": "create_exchange_proposal", "id": uuid4().hex,
  114. "args": context["proposal"], "type": "tool_call",
  115. }]))]
  116. run.next_node = "tools"
  117. run_id, token = await claim(sessions, 2)
  118. result = await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
  119. assert result["next_node"] == "wait"
  120. async with sessions() as session:
  121. run = await session.get(AgentRun, run_id)
  122. assert run.model_calls == 0
  123. assert run.waiting["expected_input_type"] == "proposal_confirmation"
  124. proposal_id = run.waiting["proposal_id"]
  125. response = await context["call"]("GET", f"/openapi/v1/tickets/{proposal_id}")
  126. assert response.status_code == 200
  127. return response.json()
  128. async def approve_ticket(context, ticket):
  129. response = await context["call"]("POST",
  130. f"/openapi/v1/approvals/{ticket['approval_id']}/decisions", "reviewer", {
  131. "proposal_version": ticket["proposal_version"], "expected_version": ticket["version"],
  132. "decision": "APPROVED", "reason": "核对客户确认及当前规则",
  133. })
  134. assert response.status_code == 200, response.text
  135. async with context["app"].state.sessions() as session:
  136. event = await session.scalar(select(BusinessEvent).where(
  137. BusinessEvent.aggregate_id == UUID(ticket["ticket_id"]).bytes,
  138. BusinessEvent.event_type == "after_sale.approved"))
  139. return event.id
  140. async def test_h5_channel_compete_quantity_and_duplicate_consumption(channel, connected): # noqa: F811
  141. async with asyncio.timeout(60):
  142. sessions = connected["app"].state.sessions
  143. h5_ticket = await make_proposal(connected)
  144. channel_ticket = await channel_proposal(channel)
  145. confirmed_h5 = await confirm_proposal(connected, h5_ticket)
  146. confirmation = envelope("proposal_confirmation", {
  147. "proposal_id": channel_ticket["proposal_id"],
  148. "proposal_version": channel_ticket["proposal_version"],
  149. "expected_ticket_version": channel_ticket["version"],
  150. })
  151. receipts = await asyncio.gather(*[channel["send"](confirmation) for _ in range(3)])
  152. assert all(item.status_code == 202 for item in receipts)
  153. assert len({item.json()["inbox_id"] for item in receipts}) == 1
  154. confirmed_channel = receipts[0].json()["result"]
  155. events = await asyncio.gather(
  156. approve_ticket(connected, confirmed_h5), approve_ticket(connected, confirmed_channel))
  157. # 两个入口在同一屏障后竞争数量,每个审批事件并发重投两次。
  158. barrier = asyncio.Barrier(4)
  159. async def consume(event):
  160. await barrier.wait()
  161. await prepare_event(sessions, event, 5)
  162. await asyncio.gather(*[consume(event) for event in events for _ in range(2)])
  163. async with sessions() as session:
  164. operations = (await session.scalars(select(ExternalOperation).where(
  165. ExternalOperation.tenant_id == channel["tenant"].bytes))).all()
  166. assert len(operations) == 1
  167. operation = operations[0]
  168. reservations = (await session.scalars(select(Reservation).where(
  169. Reservation.tenant_id == channel["tenant"].bytes))).all()
  170. assert sum(item.quantity for item in reservations) == 1
  171. tickets = (await session.scalars(select(Ticket).where(
  172. Ticket.tenant_id == channel["tenant"].bytes))).all()
  173. assert sorted(item.status for item in tickets) == ["FAILED", "PROCESSING"]
  174. assert await session.scalar(select(func.count()).select_from(ConsumerReceipt).where(
  175. ConsumerReceipt.event_id.in_(events))) == 2
  176. assert await execute_due(sessions, 5, operation.id)
  177. async with connected["oms_sessions"]() as session:
  178. exchanges = (await session.scalars(select(Exchange).where(
  179. Exchange.tenant_id == channel["tenant"].bytes))).all()
  180. assert len(exchanges) == 1 and exchanges[0].quantity == 1
  181. # 已批准事件晚到时继续复用消费收据,不重新建立操作或增加占用。
  182. await asyncio.gather(*[prepare_event(sessions, event, 5) for event in reversed(events)])
  183. async with sessions() as session:
  184. assert await session.scalar(select(func.count()).select_from(ExternalOperation).where(
  185. ExternalOperation.tenant_id == channel["tenant"].bytes)) == 1
  186. async def test_cancelled_ticket_rejects_late_approval_event(channel, connected): # noqa: F811
  187. async with asyncio.timeout(40):
  188. ticket = await confirm_proposal(connected, await channel_proposal(channel))
  189. event = await approve_ticket(connected, ticket)
  190. current = await connected["call"]("GET", f"/openapi/v1/tickets/{ticket['ticket_id']}")
  191. cancelled = await connected["call"](
  192. "POST", f"/openapi/v1/tickets/{ticket['ticket_id']}/cancel",
  193. payload={"expected_version": current.json()["version"], "reason": "客户取消申请"})
  194. assert cancelled.status_code == 200, cancelled.text
  195. sessions = connected["app"].state.sessions
  196. await asyncio.gather(*[prepare_event(sessions, event, 5) for _ in range(3)])
  197. assert await operation_for(connected, ticket) is None
  198. latest = await connected["call"]("GET", f"/openapi/v1/tickets/{ticket['ticket_id']}")
  199. assert latest.json()["status"] == "CANCELLED"
  200. async with connected["oms_sessions"]() as session:
  201. assert await session.scalar(select(func.count()).select_from(Exchange).where(
  202. Exchange.tenant_id == connected["tenant"].bytes)) == 0
  203. async def test_channel_full_oms_flow_and_ordered_tls_callbacks(
  204. channel, connected, receiver, # noqa: F811
  205. ):
  206. async with asyncio.timeout(60):
  207. sessions = connected["app"].state.sessions
  208. async with sessions.begin() as session:
  209. application = await session.get(ChannelApplication, (
  210. connected["tenant"].bytes, connected["store"].bytes))
  211. application.callback_url = receiver["url"]
  212. application.credential_env = receiver["credential_env"]
  213. ticket = await channel_proposal(channel)
  214. response = await channel["send"](envelope("proposal_confirmation", {
  215. "proposal_id": ticket["proposal_id"], "proposal_version": ticket["proposal_version"],
  216. "expected_ticket_version": ticket["version"],
  217. }))
  218. assert response.status_code == 202
  219. event = await approve_ticket(connected, response.json()["result"])
  220. await prepare_event(sessions, event, 5)
  221. operation = await operation_for(connected, ticket)
  222. assert await execute_due(sessions, 5, operation.id)
  223. completed = await connected["warehouse"].post(
  224. f"/exchanges/{UUID(bytes=operation.id)}/complete")
  225. assert completed.status_code == 200
  226. await due(connected, operation.id)
  227. assert await execute_due(sessions, 5, operation.id)
  228. query = (f"/openapi/v1/channel/identities/tickets?store_id={channel['store']}"
  229. "&external_subject=verified-channel-account")
  230. queried = await connected["call"]("GET", query, "connector")
  231. assert queried.status_code == 200
  232. assert queried.json()["items"][0]["status"] == "COMPLETED"
  233. assert queried.json()["items"][0]["ticket_id"] == ticket["ticket_id"]
  234. assert await enqueue(sessions, tenant_id=connected["tenant"].bytes) > 0
  235. assert await enqueue(sessions, tenant_id=connected["tenant"].bytes) == 0
  236. async with sessions() as session:
  237. deliveries = (await session.scalars(select(ChannelDelivery).join(
  238. BusinessEvent, BusinessEvent.id == ChannelDelivery.event_id).where(
  239. ChannelDelivery.tenant_id == connected["tenant"].bytes,
  240. BusinessEvent.aggregate_id == UUID(ticket["ticket_id"]).bytes,
  241. ).order_by(BusinessEvent.aggregate_version))).all()
  242. assert len(deliveries) >= 5
  243. assert await execute_one(sessions, deliveries[-1].id) is False
  244. receiver["accepted"].set()
  245. for delivery in deliveries:
  246. assert await execute_one(sessions, delivery.id)
  247. assert await execute_one(sessions, delivery.id) is False
  248. events = receiver["received"]
  249. assert len(events) == len(deliveries)
  250. versions = [item["aggregate_version"] for item in events]
  251. assert versions == sorted(set(versions))
  252. assert {item["aggregate_id"] for item in events} == {ticket["ticket_id"]}
  253. assert events[-1]["payload"]["operation_status"] == "COMPLETED"
  254. async with sessions() as session:
  255. for delivery in deliveries:
  256. current = await session.get(ChannelDelivery, delivery.id)
  257. assert current.status == "SENT" and current.attempts == 1
  258. async with connected["oms_sessions"]() as session:
  259. assert await session.scalar(select(func.count()).select_from(Exchange).where(
  260. Exchange.tenant_id == connected["tenant"].bytes)) == 1